mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	* remove unused TS Ignores * refactor planner into multiple pages also includes some minor UI adjustments and some feature work to improve the date selector * use mobile cards for meal-planner * remove component
		
			
				
	
	
		
			391 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			391 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <div>
 | |
|     <!-- Create Meal Dialog -->
 | |
|     <BaseDialog
 | |
|       v-model="state.dialog"
 | |
|       :title="$tc('meal-plan.create-a-new-meal-plan')"
 | |
|       color="primary"
 | |
|       :icon="$globals.icons.foods"
 | |
|       @submit="
 | |
|         actions.createOne(newMeal);
 | |
|         resetDialog();
 | |
|       "
 | |
|     >
 | |
|       <v-card-text>
 | |
|         <v-menu
 | |
|           v-model="state.pickerMenu"
 | |
|           :close-on-content-click="false"
 | |
|           transition="scale-transition"
 | |
|           offset-y
 | |
|           max-width="290px"
 | |
|           min-width="auto"
 | |
|         >
 | |
|           <template #activator="{ on, attrs }">
 | |
|             <v-text-field
 | |
|               v-model="newMeal.date"
 | |
|               :label="$t('general.date')"
 | |
|               :hint="$t('recipe.date-format-hint-yyyy-mm-dd')"
 | |
|               persistent-hint
 | |
|               :prepend-icon="$globals.icons.calendar"
 | |
|               v-bind="attrs"
 | |
|               readonly
 | |
|               v-on="on"
 | |
|             />
 | |
|           </template>
 | |
|           <v-date-picker
 | |
|             v-model="newMeal.date"
 | |
|             :first-day-of-week="firstDayOfWeek"
 | |
|             no-title
 | |
|             @input="state.pickerMenu = false"
 | |
|           />
 | |
|         </v-menu>
 | |
|         <v-card-text>
 | |
|           <v-select
 | |
|             v-model="newMeal.entryType"
 | |
|             :return-object="false"
 | |
|             :items="planTypeOptions"
 | |
|             :label="$t('recipe.entry-type')"
 | |
|           />
 | |
|           <v-autocomplete
 | |
|             v-if="!dialog.note"
 | |
|             v-model="newMeal.recipeId"
 | |
|             :label="$t('meal-plan.meal-recipe')"
 | |
|             :items="recipes.data"
 | |
|             :loading="recipes.loading"
 | |
|             :search-input.sync="recipes.search"
 | |
|             cache-items
 | |
|             item-text="name"
 | |
|             item-value="id"
 | |
|             :return-object="false"
 | |
|           />
 | |
|           <template v-else>
 | |
|             <v-text-field v-model="newMeal.title" :label="$t('meal-plan.meal-title')" />
 | |
|             <v-textarea v-model="newMeal.text" rows="2" :label="$t('meal-plan.meal-note')" />
 | |
|           </template>
 | |
|         </v-card-text>
 | |
|         <v-card-actions class="my-0 py-0">
 | |
|           <v-switch v-model="dialog.note" class="mt-n3" :label="$t('meal-plan.note-only')" />
 | |
|         </v-card-actions>
 | |
|       </v-card-text>
 | |
|     </BaseDialog>
 | |
| 
 | |
|     <v-row>
 | |
|       <v-col
 | |
|         v-for="(plan, index) in mealplans"
 | |
|         :key="index"
 | |
|         cols="12"
 | |
|         sm="12"
 | |
|         md="3"
 | |
|         lg="3"
 | |
|         xl="2"
 | |
|         class="col-borders my-1 d-flex flex-column"
 | |
|       >
 | |
|         <v-card class="mb-2 border-left-primary rounded-sm pa-2">
 | |
|           <p class="pl-2 mb-1">
 | |
|             {{ $d(plan.date, "short") }}
 | |
|           </p>
 | |
|         </v-card>
 | |
|         <draggable
 | |
|           tag="div"
 | |
|           handle=".handle"
 | |
|           :value="plan.meals"
 | |
|           group="meals"
 | |
|           :data-index="index"
 | |
|           :data-box="plan.date"
 | |
|           style="min-height: 150px"
 | |
|           @end="onMoveCallback"
 | |
|         >
 | |
|           <v-card
 | |
|             v-for="mealplan in plan.meals"
 | |
|             :key="mealplan.id"
 | |
|             class="my-1"
 | |
|             :class="{ handle: $vuetify.breakpoint.smAndUp }"
 | |
|           >
 | |
|             <v-list-item>
 | |
|               <v-list-item-avatar :rounded="false">
 | |
|                 <RecipeCardImage
 | |
|                   v-if="mealplan.recipe"
 | |
|                   :recipe-id="mealplan.recipe.id"
 | |
|                   tiny
 | |
|                   icon-size="25"
 | |
|                   :slug="mealplan.recipe ? mealplan.recipe.slug : ''"
 | |
|                 >
 | |
|                 </RecipeCardImage>
 | |
|                 <v-icon v-else>
 | |
|                   {{ $globals.icons.primary }}
 | |
|                 </v-icon>
 | |
|               </v-list-item-avatar>
 | |
|               <v-list-item-content>
 | |
|                 <v-list-item-title class="mb-1">
 | |
|                   {{ mealplan.recipe ? mealplan.recipe.name : mealplan.title }}
 | |
|                 </v-list-item-title>
 | |
|                 <v-list-item-subtitle style="min-height: 16px">
 | |
|                   {{ mealplan.recipe ? mealplan.recipe.description + " " : mealplan.text }}
 | |
|                 </v-list-item-subtitle>
 | |
|               </v-list-item-content>
 | |
|             </v-list-item>
 | |
|             <v-divider class="mx-2"></v-divider>
 | |
|             <div class="py-2 px-2 d-flex" style="align-items: center">
 | |
|               <v-btn small icon :class="{ handle: !$vuetify.breakpoint.smAndUp }">
 | |
|                 <v-icon>
 | |
|                   {{ $globals.icons.arrowUpDown }}
 | |
|                 </v-icon>
 | |
|               </v-btn>
 | |
|               <v-menu offset-y>
 | |
|                 <template #activator="{ on, attrs }">
 | |
|                   <v-chip v-bind="attrs" label small color="accent" v-on="on" @click.prevent>
 | |
|                     <v-icon left>
 | |
|                       {{ $globals.icons.tags }}
 | |
|                     </v-icon>
 | |
|                     {{ mealplan.entryType }}
 | |
|                   </v-chip>
 | |
|                 </template>
 | |
|                 <v-list>
 | |
|                   <v-list-item
 | |
|                     v-for="mealType in planTypeOptions"
 | |
|                     :key="mealType.value"
 | |
|                     @click="actions.setType(mealplan, mealType.value)"
 | |
|                   >
 | |
|                     <v-list-item-title> {{ mealType.text }} </v-list-item-title>
 | |
|                   </v-list-item>
 | |
|                 </v-list>
 | |
|               </v-menu>
 | |
|               <v-btn class="ml-auto" small icon @click="actions.deleteOne(mealplan.id)">
 | |
|                 <v-icon>{{ $globals.icons.delete }}</v-icon>
 | |
|               </v-btn>
 | |
|             </div>
 | |
|           </v-card>
 | |
|         </draggable>
 | |
|         <!-- Day Column Actions -->
 | |
|         <div class="d-flex justify-end mt-auto">
 | |
|           <BaseButtonGroup
 | |
|             :buttons="[
 | |
|               {
 | |
|                 icon: $globals.icons.diceMultiple,
 | |
|                 text: $tc('meal-plan.random-meal'),
 | |
|                 event: 'random',
 | |
|                 children: [
 | |
|                   {
 | |
|                     icon: $globals.icons.diceMultiple,
 | |
|                     text: 'Breakfast',
 | |
|                     event: 'randomBreakfast',
 | |
|                   },
 | |
|                   {
 | |
|                     icon: $globals.icons.diceMultiple,
 | |
|                     text: $tc('meal-plan.lunch'),
 | |
|                     event: 'randomLunch',
 | |
|                   },
 | |
|                 ],
 | |
|               },
 | |
|               {
 | |
|                 icon: $globals.icons.potSteam,
 | |
|                 text: $tc('meal-plan.random-dinner'),
 | |
|                 event: 'randomDinner',
 | |
|               },
 | |
|               {
 | |
|                 icon: $globals.icons.bowlMixOutline,
 | |
|                 text: $tc('meal-plan.random-side'),
 | |
|                 event: 'randomSide',
 | |
|               },
 | |
|               {
 | |
|                 icon: $globals.icons.createAlt,
 | |
|                 text: $tc('general.new'),
 | |
|                 event: 'create',
 | |
|               },
 | |
|             ]"
 | |
|             @create="openDialog(plan.date)"
 | |
|             @randomBreakfast="randomMeal(plan.date, 'breakfast')"
 | |
|             @randomLunch="randomMeal(plan.date, 'lunch')"
 | |
|             @randomDinner="randomMeal(plan.date, 'dinner')"
 | |
|             @randomSide="randomMeal(plan.date, 'side')"
 | |
|           />
 | |
|         </div>
 | |
|       </v-col>
 | |
|     </v-row>
 | |
|   </div>
 | |
| </template>
 | |
| 
 | |
| <script lang="ts">
 | |
| import { defineComponent, computed, reactive, ref, watch } from "@nuxtjs/composition-api";
 | |
| import { format } from "date-fns";
 | |
| import { SortableEvent } from "sortablejs";
 | |
| import draggable from "vuedraggable";
 | |
| import { watchDebounced } from "@vueuse/core";
 | |
| import { MealsByDate } from "./types";
 | |
| import { useMealplans, planTypeOptions } from "~/composables/use-group-mealplan";
 | |
| import RecipeCardImage from "~/components/Domain/Recipe/RecipeCardImage.vue";
 | |
| import { PlanEntryType } from "~/lib/api/types/meal-plan";
 | |
| import { useUserApi } from "~/composables/api";
 | |
| import { useGroupSelf } from "~/composables/use-groups";
 | |
| import { RecipeSummary } from "~/lib/api/types/recipe";
 | |
| 
 | |
| export default defineComponent({
 | |
|   components: {
 | |
|     draggable,
 | |
|     RecipeCardImage,
 | |
|   },
 | |
|   props: {
 | |
|     mealplans: {
 | |
|       type: Array as () => MealsByDate[],
 | |
|       required: true,
 | |
|     },
 | |
|     actions: {
 | |
|       type: Object as () => ReturnType<typeof useMealplans>["actions"],
 | |
|       required: true,
 | |
|     },
 | |
|   },
 | |
|   setup(props) {
 | |
|     const api = useUserApi();
 | |
|     const { group } = useGroupSelf();
 | |
| 
 | |
|     const state = ref({
 | |
|       dialog: false,
 | |
|       pickerMenu: null as null | boolean,
 | |
|     });
 | |
| 
 | |
|     const firstDayOfWeek = computed(() => {
 | |
|       const pref = group.value?.preferences?.firstDayOfWeek;
 | |
| 
 | |
|       if (pref) {
 | |
|         return pref;
 | |
|       }
 | |
| 
 | |
|       return 0;
 | |
|     });
 | |
| 
 | |
|     function onMoveCallback(evt: SortableEvent) {
 | |
|       const supportedEvents = ["drop", "touchend"];
 | |
| 
 | |
|       // Adapted From https://github.com/SortableJS/Vue.Draggable/issues/1029
 | |
|       const ogEvent: DragEvent = (evt as any).originalEvent;
 | |
| 
 | |
|       if (ogEvent && ogEvent.type in supportedEvents) {
 | |
|         // The drop was cancelled, unsure if anything needs to be done?
 | |
|         console.log("Cancel Move Event");
 | |
|       } else {
 | |
|         // A Meal was moved, set the new date value and make an update request and refresh the meals
 | |
|         const fromMealsByIndex = parseInt(evt.from.getAttribute("data-index") ?? "");
 | |
|         const toMealsByIndex = parseInt(evt.to.getAttribute("data-index") ?? "");
 | |
| 
 | |
|         if (!isNaN(fromMealsByIndex) && !isNaN(toMealsByIndex)) {
 | |
|           const mealData = props.mealplans[fromMealsByIndex].meals[evt.oldIndex as number];
 | |
|           const destDate = props.mealplans[toMealsByIndex].date;
 | |
| 
 | |
|           mealData.date = format(destDate, "yyyy-MM-dd");
 | |
| 
 | |
|           props.actions.updateOne(mealData);
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // =====================================================
 | |
|     // New Meal Dialog
 | |
| 
 | |
|     const dialog = reactive({
 | |
|       loading: false,
 | |
|       error: false,
 | |
|       note: false,
 | |
|     });
 | |
| 
 | |
|     watch(dialog, () => {
 | |
|       if (dialog.note) {
 | |
|         newMeal.recipeId = undefined;
 | |
|       }
 | |
|       newMeal.title = "";
 | |
|       newMeal.text = "";
 | |
|     });
 | |
| 
 | |
|     const newMeal = reactive({
 | |
|       date: "",
 | |
|       title: "",
 | |
|       text: "",
 | |
|       recipeId: undefined as string | undefined,
 | |
|       entryType: "dinner" as PlanEntryType,
 | |
|     });
 | |
| 
 | |
|     function openDialog(date: Date) {
 | |
|       newMeal.date = format(date, "yyyy-MM-dd");
 | |
|       state.value.dialog = true;
 | |
|     }
 | |
| 
 | |
|     function resetDialog() {
 | |
|       newMeal.date = "";
 | |
|       newMeal.title = "";
 | |
|       newMeal.text = "";
 | |
|       newMeal.entryType = "dinner";
 | |
|       newMeal.recipeId = undefined;
 | |
|     }
 | |
| 
 | |
|     async function randomMeal(date: Date, type: PlanEntryType) {
 | |
|       const { data } = await api.mealplans.setRandom({
 | |
|         date: format(date, "yyyy-MM-dd"),
 | |
|         entryType: type,
 | |
|       });
 | |
| 
 | |
|       if (data) {
 | |
|         props.actions.refreshAll();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // =====================================================
 | |
|     // Search
 | |
| 
 | |
|     const recipes = ref({
 | |
|       search: "",
 | |
|       loading: false,
 | |
|       error: false,
 | |
|       data: [] as RecipeSummary[],
 | |
|     });
 | |
| 
 | |
|     async function searchRecipes(term: string) {
 | |
|       recipes.value.loading = true;
 | |
|       const { data, error } = await api.recipes.search({
 | |
|         search: term,
 | |
|         page: 1,
 | |
|         orderBy: "name",
 | |
|         orderDirection: "asc",
 | |
|         perPage: 20,
 | |
|       });
 | |
| 
 | |
|       if (error) {
 | |
|         console.error(error);
 | |
|         recipes.value.loading = false;
 | |
|         recipes.value.data = [];
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       if (data) {
 | |
|         recipes.value.data = data.items;
 | |
|       }
 | |
| 
 | |
|       recipes.value.loading = false;
 | |
|     }
 | |
| 
 | |
|     watchDebounced(
 | |
|       () => recipes.value.search,
 | |
|       async (term: string) => {
 | |
|         await searchRecipes(term);
 | |
|       },
 | |
|       { debounce: 500 }
 | |
|     );
 | |
| 
 | |
|     return {
 | |
|       state,
 | |
|       onMoveCallback,
 | |
|       planTypeOptions,
 | |
| 
 | |
|       // Dialog
 | |
|       dialog,
 | |
|       newMeal,
 | |
|       openDialog,
 | |
|       resetDialog,
 | |
|       randomMeal,
 | |
| 
 | |
|       // Search
 | |
|       recipes,
 | |
|       firstDayOfWeek,
 | |
|     };
 | |
|   },
 | |
| });
 | |
| </script>
 |