mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: restore frontend sorting for all recipes (#1497)
* fixed typo * merged "all recipes" pagination into recipe card created custom sort card for all recipes refactored backend calls for all recipes to sort/paginate * frontend lint fixes * restored recipes reference * replaced "this" with reference * fix linting errors * re-order context menu * add todo Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
		| @@ -14,18 +14,50 @@ | ||||
|         </v-icon> | ||||
|         {{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }} | ||||
|       </v-btn> | ||||
|       <ContextMenu | ||||
|         v-if="!$vuetify.breakpoint.xsOnly" | ||||
|         :items="[ | ||||
|           { | ||||
|             title: 'Toggle View', | ||||
|             icon: $globals.icons.eye, | ||||
|             event: 'toggle-dense-view', | ||||
|           }, | ||||
|         ]" | ||||
|         @toggle-dense-view="mobileCards = !mobileCards" | ||||
|       /> | ||||
|  | ||||
|       <v-menu v-if="$listeners.sort" offset-y left> | ||||
|         <template #activator="{ on, attrs }"> | ||||
|           <v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on"> | ||||
|             <v-icon :left="!$vuetify.breakpoint.xsOnly"> | ||||
|               {{ $globals.icons.sort }} | ||||
|             </v-icon> | ||||
|             {{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }} | ||||
|           </v-btn> | ||||
|         </template> | ||||
|         <v-list> | ||||
|           <v-list-item @click="sortRecipesFrontend(EVENTS.az)"> | ||||
|             <v-icon left> | ||||
|               {{ $globals.icons.orderAlphabeticalAscending }} | ||||
|             </v-icon> | ||||
|             <v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title> | ||||
|           </v-list-item> | ||||
|           <v-list-item @click="sortRecipesFrontend(EVENTS.rating)"> | ||||
|             <v-icon left> | ||||
|               {{ $globals.icons.star }} | ||||
|             </v-icon> | ||||
|             <v-list-item-title>{{ $t("general.rating") }}</v-list-item-title> | ||||
|           </v-list-item> | ||||
|           <v-list-item @click="sortRecipesFrontend(EVENTS.created)"> | ||||
|             <v-icon left> | ||||
|               {{ $globals.icons.newBox }} | ||||
|             </v-icon> | ||||
|             <v-list-item-title>{{ $t("general.created") }}</v-list-item-title> | ||||
|           </v-list-item> | ||||
|           <v-list-item @click="sortRecipesFrontend(EVENTS.updated)"> | ||||
|             <v-icon left> | ||||
|               {{ $globals.icons.update }} | ||||
|             </v-icon> | ||||
|             <v-list-item-title>{{ $t("general.updated") }}</v-list-item-title> | ||||
|           </v-list-item> | ||||
|           <v-list-item @click="sortRecipesFrontend(EVENTS.shuffle)"> | ||||
|             <v-icon left> | ||||
|               {{ $globals.icons.shuffleVariant }} | ||||
|             </v-icon> | ||||
|             <v-list-item-title>{{ $t("general.shuffle") }}</v-list-item-title> | ||||
|           </v-list-item> | ||||
|         </v-list> | ||||
|       </v-menu> | ||||
|       <v-menu v-if="$listeners.sortRecipes" offset-y left> | ||||
|         <template #activator="{ on, attrs }"> | ||||
|           <v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on"> | ||||
|             <v-icon :left="!$vuetify.breakpoint.xsOnly"> | ||||
| @@ -59,14 +91,19 @@ | ||||
|             </v-icon> | ||||
|             <v-list-item-title>{{ $t("general.updated") }}</v-list-item-title> | ||||
|           </v-list-item> | ||||
|           <v-list-item @click="sortRecipes(EVENTS.shuffle)"> | ||||
|             <v-icon left> | ||||
|               {{ $globals.icons.shuffleVariant }} | ||||
|             </v-icon> | ||||
|             <v-list-item-title>{{ $t("general.shuffle") }}</v-list-item-title> | ||||
|           </v-list-item> | ||||
|         </v-list> | ||||
|       </v-menu> | ||||
|       <ContextMenu | ||||
|         v-if="!$vuetify.breakpoint.xsOnly" | ||||
|         :items="[ | ||||
|           { | ||||
|             title: 'Toggle View', | ||||
|             icon: $globals.icons.eye, | ||||
|             event: 'toggle-dense-view', | ||||
|           }, | ||||
|         ]" | ||||
|         @toggle-dense-view="mobileCards = !mobileCards" | ||||
|       /> | ||||
|     </v-app-bar> | ||||
|     <div v-if="recipes" class="mt-2"> | ||||
|       <v-row v-if="!viewScale"> | ||||
| @@ -110,17 +147,37 @@ | ||||
|         </v-col> | ||||
|       </v-row> | ||||
|     </div> | ||||
|     <div v-if="usePagination"> | ||||
|       <v-card v-intersect="infiniteScroll"></v-card> | ||||
|       <v-fade-transition> | ||||
|         <AppLoader v-if="loading" :loading="loading" /> | ||||
|       </v-fade-transition> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { computed, defineComponent, reactive, toRefs, useContext, useRouter, ref } from "@nuxtjs/composition-api"; | ||||
| import { | ||||
|   computed, | ||||
|   defineComponent, | ||||
|   onMounted, | ||||
|   reactive, | ||||
|   ref, | ||||
|   toRefs, | ||||
|   useAsync, | ||||
|   useContext, | ||||
|   useRouter, | ||||
| } from "@nuxtjs/composition-api"; | ||||
| import { useThrottleFn } from "@vueuse/core"; | ||||
| import RecipeCard from "./RecipeCard.vue"; | ||||
| import RecipeCardMobile from "./RecipeCardMobile.vue"; | ||||
| import { useSorter } from "~/composables/recipes"; | ||||
| import { useAsyncKey } from "~/composables/use-utils"; | ||||
| import { useLazyRecipes, useSorter } from "~/composables/recipes"; | ||||
| import { Recipe } from "~/types/api-types/recipe"; | ||||
|  | ||||
| const SORT_EVENT = "sort"; | ||||
| const REPLACE_RECIPES_EVENT = "replaceRecipes"; | ||||
| const APPEND_RECIPES_EVENT = "appendRecipes"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   components: { | ||||
| @@ -148,6 +205,10 @@ export default defineComponent({ | ||||
|       type: Array as () => Recipe[], | ||||
|       default: () => [], | ||||
|     }, | ||||
|     usePagination: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|   }, | ||||
|   setup(props, context) { | ||||
|     const mobileCards = ref(false); | ||||
| @@ -184,7 +245,114 @@ export default defineComponent({ | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const page = ref(1); | ||||
|     const perPage = ref(30); | ||||
|     const orderBy = ref("name"); | ||||
|     const orderDirection = ref("asc"); | ||||
|     const hasMore = ref(true); | ||||
|  | ||||
|     const ready = ref(false); | ||||
|     const loading = ref(false); | ||||
|  | ||||
|     const { recipes, fetchMore } = useLazyRecipes(); | ||||
|  | ||||
|     onMounted(async () => { | ||||
|       if (props.usePagination) { | ||||
|         const newRecipes = await fetchMore(page.value, perPage.value, orderBy.value, orderDirection.value); | ||||
|         context.emit(REPLACE_RECIPES_EVENT, newRecipes); | ||||
|         ready.value = true; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     const infiniteScroll = useThrottleFn(() => { | ||||
|       useAsync(async () => { | ||||
|         if (!ready.value || !hasMore.value || loading.value) { | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         loading.value = true; | ||||
|         page.value = page.value + 1; | ||||
|  | ||||
|         const newRecipes = await fetchMore(page.value, perPage.value, orderBy.value, orderDirection.value); | ||||
|         if (!newRecipes.length) { | ||||
|           hasMore.value = false; | ||||
|         } else { | ||||
|           context.emit(APPEND_RECIPES_EVENT, newRecipes); | ||||
|         } | ||||
|  | ||||
|         loading.value = false; | ||||
|       }, useAsyncKey()); | ||||
|     }, 500); | ||||
|  | ||||
|     /* | ||||
|     sortRecipes helps filter using the API. This will eventually replace the sortRecipesFrontend function which pulls all recipes | ||||
|     (without pagination) and does the sorting in the frontend. | ||||
|  | ||||
|     TODO: remove sortRecipesFrontend and remove duplicate "sortRecipes" section in the template (above) | ||||
|     TODO: use indicator to show asc / desc order | ||||
|     */ | ||||
|  | ||||
|     function sortRecipes(sortType: string) { | ||||
|       if (state.sortLoading || loading.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       switch (sortType) { | ||||
|         case EVENTS.az: | ||||
|           if (orderBy.value !== "name") { | ||||
|             orderBy.value = "name"; | ||||
|             orderDirection.value = "asc"; | ||||
|           } else { | ||||
|             orderDirection.value = orderDirection.value === "asc" ? "desc" : "asc"; | ||||
|           } | ||||
|           break; | ||||
|         case EVENTS.rating: | ||||
|           if (orderBy.value !== "rating") { | ||||
|             orderBy.value = "rating"; | ||||
|             orderDirection.value = "desc"; | ||||
|           } else { | ||||
|             orderDirection.value = orderDirection.value === "asc" ? "desc" : "asc"; | ||||
|           } | ||||
|           break; | ||||
|         case EVENTS.created: | ||||
|           if (orderBy.value !== "created_at") { | ||||
|             orderBy.value = "created_at"; | ||||
|             orderDirection.value = "desc"; | ||||
|           } else { | ||||
|             orderDirection.value = orderDirection.value === "asc" ? "desc" : "asc"; | ||||
|           } | ||||
|           break; | ||||
|         case EVENTS.updated: | ||||
|           if (orderBy.value !== "update_at") { | ||||
|             orderBy.value = "update_at"; | ||||
|             orderDirection.value = "desc"; | ||||
|           } else { | ||||
|             orderDirection.value = orderDirection.value === "asc" ? "desc" : "asc"; | ||||
|           } | ||||
|           break; | ||||
|         default: | ||||
|           console.log("Unknown Event", sortType); | ||||
|           return; | ||||
|       } | ||||
|  | ||||
|       useAsync(async () => { | ||||
|         // reset pagination | ||||
|         page.value = 1; | ||||
|         hasMore.value = true; | ||||
|  | ||||
|         state.sortLoading = true; | ||||
|         loading.value = true; | ||||
|  | ||||
|         // fetch new recipes | ||||
|         const newRecipes = await fetchMore(page.value, perPage.value, orderBy.value, orderDirection.value); | ||||
|         context.emit(REPLACE_RECIPES_EVENT, newRecipes); | ||||
|  | ||||
|         state.sortLoading = false; | ||||
|         loading.value = false; | ||||
|       }, useAsyncKey()); | ||||
|     } | ||||
|  | ||||
|     function sortRecipesFrontend(sortType: string) { | ||||
|       state.sortLoading = true; | ||||
|       const sortTarget = [...props.recipes]; | ||||
|       switch (sortType) { | ||||
| @@ -217,8 +385,11 @@ export default defineComponent({ | ||||
|       EVENTS, | ||||
|       viewScale, | ||||
|       displayTitleIcon, | ||||
|       infiniteScroll, | ||||
|       loading, | ||||
|       navigateRandom, | ||||
|       sortRecipes, | ||||
|       sortRecipesFrontend, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
|   | ||||
| @@ -63,11 +63,7 @@ export const useLazyRecipes = function () { | ||||
|  | ||||
|   async function fetchMore(page: number, perPage: number, orderBy: string | null = null, orderDirection = "desc") { | ||||
|     const { data } = await api.recipes.getAll(page, perPage, { orderBy, orderDirection }); | ||||
|     if (data) { | ||||
|       data.items.forEach((recipe) => { | ||||
|         recipes.value?.push(recipe); | ||||
|       }); | ||||
|     } | ||||
|     return data ? data.items : []; | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|   | ||||
| @@ -4,48 +4,35 @@ | ||||
|       :icon="$globals.icons.primary" | ||||
|       :title="$t('page.all-recipes')" | ||||
|       :recipes="recipes" | ||||
|       :use-pagination="true" | ||||
|       @sortRecipes="assignSorted" | ||||
|       @replaceRecipes="replaceRecipes" | ||||
|       @appendRecipes="appendRecipes" | ||||
|       @delete="removeRecipe" | ||||
|     ></RecipeCardSection> | ||||
|     <v-card v-intersect="infiniteScroll"></v-card> | ||||
|     <v-fade-transition> | ||||
|       <AppLoader v-if="loading" :loading="loading" /> | ||||
|     </v-fade-transition> | ||||
|   </v-container> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent, onMounted, ref } from "@nuxtjs/composition-api"; | ||||
| import { useThrottleFn } from "@vueuse/core"; | ||||
| import { defineComponent } from "@nuxtjs/composition-api"; | ||||
| import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; | ||||
| import { useLazyRecipes } from "~/composables/recipes"; | ||||
| import { Recipe } from "~/types/api-types/recipe"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   components: { RecipeCardSection }, | ||||
|   setup() { | ||||
|     const page = ref(1); | ||||
|     const perPage = ref(30); | ||||
|     const orderBy = "name"; | ||||
|     const orderDirection = "asc"; | ||||
|  | ||||
|     const ready = ref(false); | ||||
|     const loading = ref(false); | ||||
|  | ||||
|     const { recipes, fetchMore } = useLazyRecipes(); | ||||
|  | ||||
|     onMounted(async () => { | ||||
|       await fetchMore(page.value, perPage.value, orderBy, orderDirection); | ||||
|       ready.value = true; | ||||
|     function appendRecipes(val: Array<Recipe>) { | ||||
|       val.forEach((recipe) => { | ||||
|         recipes.value.push(recipe); | ||||
|       }); | ||||
|  | ||||
|     const infiniteScroll = useThrottleFn(() => { | ||||
|       if (!ready.value) { | ||||
|         return; | ||||
|     } | ||||
|       loading.value = true; | ||||
|       page.value = page.value + 1; | ||||
|       fetchMore(page.value, perPage.value, orderBy, orderDirection); | ||||
|       loading.value = false; | ||||
|     }, 500); | ||||
|  | ||||
|     function assignSorted(val: Array<Recipe>) { | ||||
|       recipes.value = val; | ||||
|     } | ||||
|  | ||||
|     function removeRecipe(slug: string) { | ||||
|       for (let i = 0; i < recipes?.value?.length; i++) { | ||||
| @@ -56,7 +43,11 @@ export default defineComponent({ | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return { recipes, infiniteScroll, loading, removeRecipe }; | ||||
|     function replaceRecipes(val: Array<Recipe>) { | ||||
|       recipes.value = val; | ||||
|     } | ||||
|  | ||||
|     return { appendRecipes, assignSorted, recipes, removeRecipe, replaceRecipes }; | ||||
|   }, | ||||
|   head() { | ||||
|     return { | ||||
|   | ||||
| @@ -30,7 +30,7 @@ export default defineComponent({ | ||||
|     }; | ||||
|   }, | ||||
|   head: { | ||||
|     title: "Tags", | ||||
|     title: "Categories", | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user