mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 18:48:05 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			376 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			376 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <div v-if="dialog">
 | |
|     <BaseDialog v-if="shoppingListDialog" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck">
 | |
|       <v-card-text>
 | |
|         <v-card
 | |
|           v-for="list in shoppingListChoices"
 | |
|           :key="list.id"
 | |
|           hover
 | |
|           class="my-2 left-border"
 | |
|           @click="openShoppingListIngredientDialog(list)"
 | |
|         >
 | |
|           <v-card-title class="py-2">
 | |
|             {{ list.name }}
 | |
|           </v-card-title>
 | |
|         </v-card>
 | |
|       </v-card-text>
 | |
|       <template #card-actions>
 | |
|         <v-btn
 | |
|           text
 | |
|           color="grey"
 | |
|           @click="dialog = false"
 | |
|         >
 | |
|           {{ $t("general.cancel") }}
 | |
|         </v-btn>
 | |
|         <div class="d-flex justify-end" style="width: 100%;">
 | |
|           <v-checkbox v-model="preferences.viewAllLists" hide-details :label="$tc('general.show-all')" class="my-auto mr-4" />
 | |
|         </div>
 | |
|       </template>
 | |
|     </BaseDialog>
 | |
|     <BaseDialog
 | |
|       v-if="shoppingListIngredientDialog"
 | |
|       v-model="dialog"
 | |
|       :title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
 | |
|       :icon="$globals.icons.cartCheck"
 | |
|       width="70%"
 | |
|       :submit-text="$tc('recipe.add-to-list')"
 | |
|       @submit="addRecipesToList()"
 | |
|     >
 | |
|       <div style="max-height: 70vh;  overflow-y: auto">
 | |
|         <v-card
 | |
|           v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections" :key="recipeSection.recipeId + recipeSectionIndex"
 | |
|           elevation="0"
 | |
|           height="fit-content"
 | |
|           width="100%"
 | |
|         >
 | |
|           <v-divider v-if="recipeSectionIndex > 0" class="mt-3" />
 | |
|           <v-card-title
 | |
|             v-if="recipeIngredientSections.length > 1"
 | |
|             class="justify-center text-h5"
 | |
|             width="100%"
 | |
|           >
 | |
|             <v-container style="width: 100%;">
 | |
|               <v-row no-gutters class="ma-0 pa-0">
 | |
|                 <v-col cols="12" align-self="center" class="text-center">
 | |
|                   {{ recipeSection.recipeName }}
 | |
|                 </v-col>
 | |
|               </v-row>
 | |
|               <v-row v-if="recipeSection.recipeScale > 1" no-gutters class="ma-0 pa-0">
 | |
|                 <!-- TODO: make this editable in the dialog and visible on single-recipe lists -->
 | |
|                 <v-col cols="12" align-self="center" class="text-center">
 | |
|                   ({{ $tc("recipe.quantity") }}: {{ recipeSection.recipeScale }})
 | |
|                 </v-col>
 | |
|               </v-row>
 | |
|             </v-container>
 | |
|           </v-card-title>
 | |
|           <div>
 | |
|             <div
 | |
|               v-for="(ingredientSection, ingredientSectionIndex) in recipeSection.ingredientSections"
 | |
|               :key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex"
 | |
|             >
 | |
|               <v-card-title v-if="ingredientSection.sectionName" class="ingredient-title mt-2 pb-0 text-h6">
 | |
|                 {{ ingredientSection.sectionName }}
 | |
|               </v-card-title>
 | |
|               <div
 | |
|                 :class="$vuetify.breakpoint.smAndDown ? '' : 'ingredient-grid'"
 | |
|                 :style="$vuetify.breakpoint.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
 | |
|               >
 | |
|                 <v-list-item
 | |
|                   v-for="(ingredientData, i) in ingredientSection.ingredients"
 | |
|                   :key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex + i"
 | |
|                   dense
 | |
|                   @click="recipeIngredientSections[recipeSectionIndex]
 | |
|                     .ingredientSections[ingredientSectionIndex]
 | |
|                     .ingredients[i].checked = !recipeIngredientSections[recipeSectionIndex]
 | |
|                     .ingredientSections[ingredientSectionIndex]
 | |
|                     .ingredients[i]
 | |
|                     .checked"
 | |
|                 >
 | |
|                   <v-checkbox
 | |
|                     hide-details
 | |
|                     :input-value="ingredientData.checked"
 | |
|                     class="pt-0 my-auto py-auto"
 | |
|                     color="secondary"
 | |
|                   />
 | |
|                   <v-list-item-content :key="ingredientData.ingredient.quantity">
 | |
|                     <RecipeIngredientListItem
 | |
|                       :ingredient="ingredientData.ingredient"
 | |
|                       :disable-amount="ingredientData.disableAmount"
 | |
|                       :scale="recipeSection.recipeScale" />
 | |
|                   </v-list-item-content>
 | |
|                 </v-list-item>
 | |
|               </div>
 | |
|             </div>
 | |
|           </div>
 | |
|         </v-card>
 | |
|       </div>
 | |
|       <div class="d-flex justify-end mb-4 mt-2">
 | |
|         <BaseButtonGroup
 | |
|           :buttons="[
 | |
|             {
 | |
|               icon: $globals.icons.checkboxBlankOutline,
 | |
|               text: $tc('shopping-list.uncheck-all-items'),
 | |
|               event: 'uncheck',
 | |
|             },
 | |
|             {
 | |
|               icon: $globals.icons.checkboxOutline,
 | |
|               text: $tc('shopping-list.check-all-items'),
 | |
|               event: 'check',
 | |
|             },
 | |
|           ]"
 | |
|           @uncheck="bulkCheckIngredients(false)"
 | |
|           @check="bulkCheckIngredients(true)"
 | |
|         />
 | |
|       </div>
 | |
|     </BaseDialog>
 | |
|   </div>
 | |
| </template>
 | |
| 
 | |
| <script lang="ts">
 | |
| import { computed, defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
 | |
| import { toRefs } from "@vueuse/core";
 | |
| import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
 | |
| import { useUserApi } from "~/composables/api";
 | |
| import { alert } from "~/composables/use-toast";
 | |
| import { useShoppingListPreferences } from "~/composables/use-users/preferences";
 | |
| import { ShoppingListSummary } from "~/lib/api/types/group";
 | |
| import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
 | |
| 
 | |
| export interface RecipeWithScale extends Recipe {
 | |
|   scale: number;
 | |
| }
 | |
| 
 | |
| export interface ShoppingListIngredient {
 | |
|   checked: boolean;
 | |
|   ingredient: RecipeIngredient;
 | |
|   disableAmount: boolean;
 | |
| }
 | |
| 
 | |
| export interface ShoppingListIngredientSection {
 | |
|   sectionName: string;
 | |
|   ingredients: ShoppingListIngredient[];
 | |
| }
 | |
| 
 | |
| export interface ShoppingListRecipeIngredientSection {
 | |
|   recipeId: string;
 | |
|   recipeName: string;
 | |
|   recipeScale: number;
 | |
|   ingredientSections: ShoppingListIngredientSection[];
 | |
| }
 | |
| 
 | |
| export default defineComponent({
 | |
|   components: {
 | |
|     RecipeIngredientListItem,
 | |
|   },
 | |
|   props: {
 | |
|     value: {
 | |
|       type: Boolean,
 | |
|       default: false,
 | |
|     },
 | |
|     recipes: {
 | |
|       type: Array as () => RecipeWithScale[],
 | |
|       default: undefined,
 | |
|     },
 | |
|     shoppingLists: {
 | |
|       type: Array as () => ShoppingListSummary[],
 | |
|       default: () => [],
 | |
|     },
 | |
|   },
 | |
|   setup(props, context) {
 | |
|     const { $auth, i18n } = useContext();
 | |
|     const api = useUserApi();
 | |
|     const preferences = useShoppingListPreferences();
 | |
| 
 | |
|     // v-model support
 | |
|     const dialog = computed({
 | |
|       get: () => {
 | |
|         return props.value;
 | |
|       },
 | |
|       set: (val) => {
 | |
|         context.emit("input", val);
 | |
|         initState();
 | |
|       },
 | |
|     });
 | |
| 
 | |
|     const state = reactive({
 | |
|       shoppingListDialog: true,
 | |
|       shoppingListIngredientDialog: false,
 | |
|     });
 | |
| 
 | |
|     const shoppingListChoices = computed(() => {
 | |
|       return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id);
 | |
|     });
 | |
| 
 | |
|     const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
 | |
|     const selectedShoppingList = ref<ShoppingListSummary | null>(null);
 | |
| 
 | |
|     async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
 | |
|       const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
 | |
|       for (const recipe of recipes) {
 | |
|         if (!recipe.slug) {
 | |
|           continue;
 | |
|         }
 | |
| 
 | |
|         if (recipeSectionMap.has(recipe.slug)) {
 | |
|           // @ts-ignore not undefined, see above
 | |
|           recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
 | |
|           continue;
 | |
|         }
 | |
| 
 | |
|         if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
 | |
|           const { data } = await api.recipes.getOne(recipe.slug);
 | |
|           if (!data?.recipeIngredient?.length) {
 | |
|             continue;
 | |
|           }
 | |
|           recipe.id = data.id || "";
 | |
|           recipe.name = data.name || "";
 | |
|           recipe.recipeIngredient = data.recipeIngredient;
 | |
|         } else if (!recipe.recipeIngredient.length) {
 | |
|           continue;
 | |
|         }
 | |
| 
 | |
|         const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
 | |
|           return {
 | |
|             checked: true,
 | |
|             ingredient: ing,
 | |
|             disableAmount: recipe.settings?.disableAmount || false,
 | |
|           }
 | |
|         });
 | |
| 
 | |
|         const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
 | |
|           // if title append new section to the end of the array
 | |
|           if (ing.ingredient.title) {
 | |
|             sections.push({
 | |
|               sectionName: ing.ingredient.title,
 | |
|               ingredients: [ing],
 | |
|             });
 | |
|             return sections;
 | |
|           }
 | |
| 
 | |
|           // append new section if first
 | |
|           if (sections.length === 0) {
 | |
|             sections.push({
 | |
|               sectionName: "",
 | |
|               ingredients: [ing],
 | |
|             });
 | |
|             return sections;
 | |
|           }
 | |
| 
 | |
|           // otherwise add ingredient to last section in the array
 | |
|           sections[sections.length - 1].ingredients.push(ing);
 | |
|           return sections;
 | |
|         }, [] as ShoppingListIngredientSection[]);
 | |
| 
 | |
|         recipeSectionMap.set(recipe.slug, {
 | |
|           recipeId: recipe.id,
 | |
|           recipeName: recipe.name,
 | |
|           recipeScale: recipe.scale,
 | |
|           ingredientSections: shoppingListIngredientSections,
 | |
|         })
 | |
|       }
 | |
| 
 | |
|       recipeIngredientSections.value = Array.from(recipeSectionMap.values());
 | |
|     }
 | |
| 
 | |
|     function initState() {
 | |
|       state.shoppingListDialog = true;
 | |
|       state.shoppingListIngredientDialog = false;
 | |
|       recipeIngredientSections.value = [];
 | |
|       selectedShoppingList.value = null;
 | |
|     }
 | |
| 
 | |
|     initState();
 | |
| 
 | |
|     async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
 | |
|       if (!props.recipes?.length) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       selectedShoppingList.value = list;
 | |
|       await consolidateRecipesIntoSections(props.recipes);
 | |
|       state.shoppingListDialog = false;
 | |
|       state.shoppingListIngredientDialog = true;
 | |
|     }
 | |
| 
 | |
|     function bulkCheckIngredients(value = true) {
 | |
|       recipeIngredientSections.value.forEach((recipeSection) => {
 | |
|         recipeSection.ingredientSections.forEach((ingSection) => {
 | |
|           ingSection.ingredients.forEach((ing) => {
 | |
|             ing.checked = value;
 | |
|           });
 | |
|         });
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     async function addRecipesToList() {
 | |
|       const promises: Promise<any>[] = [];
 | |
|       recipeIngredientSections.value.forEach((section) => {
 | |
|         if (!selectedShoppingList.value) {
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         const ingredients: RecipeIngredient[] = [];
 | |
|         section.ingredientSections.forEach((ingSection) => {
 | |
|           ingSection.ingredients.forEach((ing) => {
 | |
|             if (ing.checked) {
 | |
|               ingredients.push(ing.ingredient);
 | |
|             }
 | |
|           });
 | |
|         });
 | |
| 
 | |
|         if (!ingredients.length) {
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         promises.push(api.shopping.lists.addRecipe(
 | |
|           selectedShoppingList.value.id,
 | |
|           section.recipeId,
 | |
|           section.recipeScale,
 | |
|           ingredients,
 | |
|         ));
 | |
|       });
 | |
| 
 | |
|       let success = true;
 | |
|       const results = await Promise.allSettled(promises);
 | |
|       results.forEach((result) => {
 | |
|         if (result.status === "rejected") {
 | |
|           success = false;
 | |
|         }
 | |
|       })
 | |
| 
 | |
|       const successMessage = promises.length === 1
 | |
|         ? i18n.t("recipe.successfully-added-to-list") as string
 | |
|         : i18n.t("recipe.failed-to-add-to-list") as string;
 | |
| 
 | |
|       success ? alert.success(successMessage)
 | |
|       : alert.error(i18n.t("failed-to-add-recipes-to-list") as string)
 | |
| 
 | |
|       state.shoppingListDialog = false;
 | |
|       state.shoppingListIngredientDialog = false;
 | |
|       dialog.value = false;
 | |
|     }
 | |
| 
 | |
|     return {
 | |
|       dialog,
 | |
|       preferences,
 | |
|       shoppingListChoices,
 | |
|       ...toRefs(state),
 | |
|       addRecipesToList,
 | |
|       bulkCheckIngredients,
 | |
|       openShoppingListIngredientDialog,
 | |
|       recipeIngredientSections,
 | |
|       selectedShoppingList,
 | |
|     }
 | |
|   },
 | |
| })
 | |
| </script>
 | |
| 
 | |
| <style scoped lang="css">
 | |
| .ingredient-grid {
 | |
|   display: grid;
 | |
|   grid-auto-flow: column;
 | |
|   grid-template-columns: 1fr 1fr;
 | |
|   grid-gap: 0.5rem;
 | |
| }
 | |
| </style>
 |