mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-30 17:53:31 -04:00 
			
		
		
		
	fix: ingredient linker and instructions titles (#6146)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
		| @@ -37,7 +37,7 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| .handle { | .handle { | ||||||
|   cursor: grab; |   cursor: grab !important; | ||||||
| } | } | ||||||
|  |  | ||||||
| .hidden { | .hidden { | ||||||
|   | |||||||
| @@ -1,15 +1,44 @@ | |||||||
| <template> | <template> | ||||||
|   <!-- eslint-disable-next-line vue/no-v-html --> |   <div class="ingredient-link-label links-disabled"> | ||||||
|   <div v-html="safeMarkup" /> |     <SafeMarkdown v-if="baseText" :source="baseText" /> | ||||||
|  |     <SafeMarkdown | ||||||
|  |       v-if="ingredient?.note" | ||||||
|  |       class="d-inline" | ||||||
|  |       :source="` ${ingredient.note}`" | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients"; | import { computed } from "vue"; | ||||||
|  | import type { RecipeIngredient } from "~/lib/api/types/recipe"; | ||||||
|  | import { useParsedIngredientText } from "~/composables/recipes"; | ||||||
|  |  | ||||||
| interface Props { | interface Props { | ||||||
|   markup: string; |   ingredient?: RecipeIngredient; | ||||||
|  |   scale?: number; | ||||||
| } | } | ||||||
| const props = defineProps<Props>(); |  | ||||||
|  |  | ||||||
| const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup)); | const { ingredient, scale = 1 } = defineProps<Props>(); | ||||||
|  |  | ||||||
|  | const baseText = computed(() => { | ||||||
|  |   if (!ingredient) return ""; | ||||||
|  |   const parsed = useParsedIngredientText(ingredient, scale); | ||||||
|  |   return [parsed.quantity, parsed.unit, parsed.name].filter(Boolean).join(" ").trim(); | ||||||
|  | }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .ingredient-link-label { | ||||||
|  |   display: block; | ||||||
|  |   line-height: 1.25; | ||||||
|  |   word-break: break-word; | ||||||
|  |   font-size: 0.95rem; | ||||||
|  | } | ||||||
|  | .links-disabled :deep(a) { | ||||||
|  |   pointer-events: none; | ||||||
|  |   cursor: default; | ||||||
|  |   color: var(--v-theme-primary); | ||||||
|  |   text-decoration: none; | ||||||
|  | } | ||||||
|  | </style> | ||||||
|   | |||||||
| @@ -34,21 +34,21 @@ | |||||||
|               {{ $t("recipe.unlinked") }} |               {{ $t("recipe.unlinked") }} | ||||||
|             </h4> |             </h4> | ||||||
|             <template v-for="(ingredients, title) in groupedUnusedIngredients" :key="title"> |             <template v-for="(ingredients, title) in groupedUnusedIngredients" :key="title"> | ||||||
|             <h4 v-if="title" class="py-3 ml-1 pl-4"> |               <h4 v-if="title" class="py-3 ml-1 pl-4"> | ||||||
|               {{ title }} |                 {{ title }} | ||||||
|             </h4> |               </h4> | ||||||
|             <v-checkbox-btn |               <v-checkbox-btn | ||||||
|               v-for="ing in ingredients" |                 v-for="ing in ingredients" | ||||||
|               :key="ing.referenceId" |                 :key="ing.referenceId" | ||||||
|               v-model="activeRefs" |                 v-model="activeRefs" | ||||||
|               :value="ing.referenceId" |                 :value="ing.referenceId" | ||||||
|               class="ml-4" |                 class="ml-4" | ||||||
|             > |               > | ||||||
|               <template #label> |                 <template #label> | ||||||
|                 <RecipeIngredientHtml :markup="parseIngredientText(ing)" /> |                   <RecipeIngredientHtml :ingredient="ing" :scale="scale" /> | ||||||
|               </template> |                 </template> | ||||||
|             </v-checkbox-btn> |               </v-checkbox-btn> | ||||||
|           </template> |             </template> | ||||||
|           </template> |           </template> | ||||||
|  |  | ||||||
|           <template v-if="Object.keys(groupedUsedIngredients).length > 0"> |           <template v-if="Object.keys(groupedUsedIngredients).length > 0"> | ||||||
| @@ -67,7 +67,7 @@ | |||||||
|                 class="ml-4" |                 class="ml-4" | ||||||
|               > |               > | ||||||
|                 <template #label> |                 <template #label> | ||||||
|                   <RecipeIngredientHtml :markup="parseIngredientText(ing)" /> |                   <RecipeIngredientHtml :ingredient="ing" :scale="scale" /> | ||||||
|                 </template> |                 </template> | ||||||
|               </v-checkbox-btn> |               </v-checkbox-btn> | ||||||
|             </template> |             </template> | ||||||
| @@ -184,17 +184,17 @@ | |||||||
|           <v-hover v-slot="{ isHovering }"> |           <v-hover v-slot="{ isHovering }"> | ||||||
|             <v-card |             <v-card | ||||||
|               class="my-3" |               class="my-3" | ||||||
|               :class="[{ 'on-hover': isHovering }, isChecked(index)]" |               :class="[{ 'on-hover': isHovering }, { 'cursor-default': isEditForm }, isChecked(index)]" | ||||||
|               :elevation="isHovering ? 12 : 2" |               :elevation="isHovering ? 12 : 2" | ||||||
|               :ripple="false" |               :ripple="false" | ||||||
|               @click="toggleDisabled(index)" |               @click="toggleDisabled(index)" | ||||||
|             > |             > | ||||||
|               <v-card-title :class="{ 'pb-0': !isChecked(index) }"> |               <v-card-title class="recipe-step-title pt-3" :class="!isChecked(index) ? 'pb-0' : 'pb-3'"> | ||||||
|                 <div class="d-flex align-center"> |                 <div class="d-flex align-center w-100"> | ||||||
|                   <v-text-field |                   <v-text-field | ||||||
|                     v-if="isEditForm" |                     v-if="isEditForm" | ||||||
|                     v-model="step.summary" |                     v-model="step.summary" | ||||||
|                     class="headline handle" |                     class="headline" | ||||||
|                     hide-details |                     hide-details | ||||||
|                     density="compact" |                     density="compact" | ||||||
|                     variant="solo" |                     variant="solo" | ||||||
| @@ -202,14 +202,27 @@ | |||||||
|                     :placeholder="$t('recipe.step-index', { step: index + 1 })" |                     :placeholder="$t('recipe.step-index', { step: index + 1 })" | ||||||
|                   > |                   > | ||||||
|                     <template #prepend> |                     <template #prepend> | ||||||
|                       <v-icon size="26"> |                       <v-icon size="26" class="handle"> | ||||||
|                         {{ $globals.icons.arrowUpDown }} |                         {{ $globals.icons.arrowUpDown }} | ||||||
|                       </v-icon> |                       </v-icon> | ||||||
|                     </template> |                     </template> | ||||||
|                   </v-text-field> |                   </v-text-field> | ||||||
|                   <span v-else> |                   <div | ||||||
|                     {{ step.summary ? step.summary : $t("recipe.step-index", { step: index + 1 }) }} |                     v-else | ||||||
|                   </span> |                     class="summary-wrapper" | ||||||
|  |                   > | ||||||
|  |                     <template v-if="step.summary"> | ||||||
|  |                       <SafeMarkdown | ||||||
|  |                         class="pr-2" | ||||||
|  |                         :source="step.summary" | ||||||
|  |                       /> | ||||||
|  |                     </template> | ||||||
|  |                     <template v-else> | ||||||
|  |                       <span> | ||||||
|  |                         {{ $t('recipe.step-index', { step: index + 1 }) }} | ||||||
|  |                       </span> | ||||||
|  |                     </template> | ||||||
|  |                   </div> | ||||||
|                   <template v-if="isEditForm"> |                   <template v-if="isEditForm"> | ||||||
|                     <div class="ml-auto"> |                     <div class="ml-auto"> | ||||||
|                       <BaseButtonGroup |                       <BaseButtonGroup | ||||||
| @@ -314,11 +327,22 @@ | |||||||
|                       persistentHint: true, |                       persistentHint: true, | ||||||
|                     }" |                     }" | ||||||
|                   /> |                   /> | ||||||
|                   <RecipeIngredientHtml |                   <div | ||||||
|                     v-for="ing in step.ingredientReferences" |                     v-if="step.ingredientReferences && step.ingredientReferences.length" | ||||||
|                     :key="ing.referenceId!" |                     class="linked-ingredients-editor" | ||||||
|                     :markup="getIngredientByRefId(ing.referenceId!)" |                   > | ||||||
|                   /> |                     <div | ||||||
|  |                       v-for="(linkRef, i) in step.ingredientReferences" | ||||||
|  |                       :key="linkRef.referenceId ?? i" | ||||||
|  |                       class="mb-1" | ||||||
|  |                     > | ||||||
|  |                       <RecipeIngredientHtml | ||||||
|  |                         v-if="linkRef.referenceId && ingredientLookup[linkRef.referenceId]" | ||||||
|  |                         :ingredient="ingredientLookup[linkRef.referenceId]" | ||||||
|  |                         :scale="scale" | ||||||
|  |                       /> | ||||||
|  |                     </div> | ||||||
|  |                   </div> | ||||||
|                 </v-card-text> |                 </v-card-text> | ||||||
|               </DropZone> |               </DropZone> | ||||||
|               <v-expand-transition> |               <v-expand-transition> | ||||||
| @@ -373,9 +397,7 @@ | |||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { VueDraggable } from "vue-draggable-plus"; | import { VueDraggable } from "vue-draggable-plus"; | ||||||
| import { computed, nextTick, onMounted, ref, watch } from "vue"; | import { computed, nextTick, onMounted, ref, watch } from "vue"; | ||||||
| import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue"; |  | ||||||
| import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe"; | import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe"; | ||||||
| import { parseIngredientText } from "~/composables/recipes"; |  | ||||||
| import { uuid4 } from "~/composables/use-utils"; | import { uuid4 } from "~/composables/use-utils"; | ||||||
| import { useUserApi, useStaticRoutes } from "~/composables/api"; | import { useUserApi, useStaticRoutes } from "~/composables/api"; | ||||||
| import { usePageState } from "~/composables/recipe-page/shared-state"; | import { usePageState } from "~/composables/recipe-page/shared-state"; | ||||||
| @@ -383,6 +405,7 @@ import { useExtractIngredientReferences } from "~/composables/recipe-page/use-ex | |||||||
| import type { NoUndefinedField } from "~/lib/api/types/non-generated"; | import type { NoUndefinedField } from "~/lib/api/types/non-generated"; | ||||||
| import DropZone from "~/components/global/DropZone.vue"; | import DropZone from "~/components/global/DropZone.vue"; | ||||||
| import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue"; | import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue"; | ||||||
|  | import RecipeIngredientHtml from "~/components/Domain/Recipe/RecipeIngredientHtml.vue"; | ||||||
|  |  | ||||||
| interface MergerHistory { | interface MergerHistory { | ||||||
|   target: number; |   target: number; | ||||||
| @@ -500,10 +523,9 @@ function openDialog(idx: number, text: string, refs?: IngredientReferences[]) { | |||||||
|     instructionList.value[idx].ingredientReferences = []; |     instructionList.value[idx].ingredientReferences = []; | ||||||
|     refs = instructionList.value[idx].ingredientReferences as IngredientReferences[]; |     refs = instructionList.value[idx].ingredientReferences as IngredientReferences[]; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   setUsedIngredients(); |  | ||||||
|   activeText.value = text; |  | ||||||
|   activeIndex.value = idx; |   activeIndex.value = idx; | ||||||
|  |   activeText.value = text; | ||||||
|  |   setUsedIngredients(); | ||||||
|   dialog.value = true; |   dialog.value = true; | ||||||
|   activeRefs.value = refs.map(ref => ref.referenceId ?? ""); |   activeRefs.value = refs.map(ref => ref.referenceId ?? ""); | ||||||
| } | } | ||||||
| @@ -544,29 +566,26 @@ function saveAndOpenNextLinkIngredients() { | |||||||
| function setUsedIngredients() { | function setUsedIngredients() { | ||||||
|   const usedRefs: { [key: string]: boolean } = {}; |   const usedRefs: { [key: string]: boolean } = {}; | ||||||
|  |  | ||||||
|   instructionList.value.forEach((element) => { |   instructionList.value.forEach((element, idx) => { | ||||||
|  |     if (idx === activeIndex.value) return; | ||||||
|     element.ingredientReferences?.forEach((ref) => { |     element.ingredientReferences?.forEach((ref) => { | ||||||
|       if (ref.referenceId !== undefined) { |       if (ref.referenceId) usedRefs[ref.referenceId] = true; | ||||||
|         usedRefs[ref.referenceId!] = true; |  | ||||||
|       } |  | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   usedIngredients.value = props.recipe.recipeIngredient.filter((ing) => { |   usedIngredients.value = props.recipe.recipeIngredient.filter(ing => !!ing.referenceId && ing.referenceId in usedRefs); | ||||||
|     return ing.referenceId !== undefined && ing.referenceId in usedRefs; |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   unusedIngredients.value = props.recipe.recipeIngredient.filter((ing) => { |   unusedIngredients.value = props.recipe.recipeIngredient.filter(ing => !!ing.referenceId && !(ing.referenceId in usedRefs)); | ||||||
|     return !(ing.referenceId !== undefined && ing.referenceId in usedRefs); |  | ||||||
|   }); |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | watch(activeRefs, () => setUsedIngredients()); | ||||||
|  |  | ||||||
| function autoSetReferences() { | function autoSetReferences() { | ||||||
|   useExtractIngredientReferences( |   useExtractIngredientReferences( | ||||||
|     props.recipe.recipeIngredient, |     props.recipe.recipeIngredient, | ||||||
|     activeRefs.value, |     activeRefs.value, | ||||||
|     activeText.value, |     activeText.value, | ||||||
|   ).forEach((ingredient: string) => activeRefs.value.push(ingredient)); |   ).forEach(ingredient => activeRefs.value.push(ingredient)); | ||||||
| } | } | ||||||
|  |  | ||||||
| const ingredientLookup = computed(() => { | const ingredientLookup = computed(() => { | ||||||
| @@ -603,8 +622,8 @@ const ingredientSectionTitles = computed(() => { | |||||||
|   return titleMap; |   return titleMap; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const groupedUnusedIngredients = computed(() => { | const groupedUnusedIngredients = computed((): Record<string, RecipeIngredient[]> => { | ||||||
|   const groups: { [key: string]: RecipeIngredient[] } = {}; |   const groups: Record<string, RecipeIngredient[]> = {}; | ||||||
|  |  | ||||||
|   // Group ingredients by section title |   // Group ingredients by section title | ||||||
|   unusedIngredients.value.forEach((ingredient) => { |   unusedIngredients.value.forEach((ingredient) => { | ||||||
| @@ -614,20 +633,14 @@ const groupedUnusedIngredients = computed(() => { | |||||||
|  |  | ||||||
|     // Use the section title from the mapping, or fallback to the ingredient's own title |     // Use the section title from the mapping, or fallback to the ingredient's own title | ||||||
|     const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || ""; |     const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || ""; | ||||||
|  |     (groups[title] ||= []).push(ingredient); | ||||||
|     if (!groups[title]) { |  | ||||||
|       groups[title] = []; |  | ||||||
|     } |  | ||||||
|     groups[title].push(ingredient); |  | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   return groups; |   return groups; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const groupedUsedIngredients = computed(() => { | const groupedUsedIngredients = computed((): Record<string, RecipeIngredient[]> => { | ||||||
|   const groups: { [key: string]: RecipeIngredient[] } = {}; |   const groups: Record<string, RecipeIngredient[]> = {}; | ||||||
|  |  | ||||||
|   // Group ingredients by section title |  | ||||||
|   usedIngredients.value.forEach((ingredient) => { |   usedIngredients.value.forEach((ingredient) => { | ||||||
|     if (ingredient.referenceId === undefined) { |     if (ingredient.referenceId === undefined) { | ||||||
|       return; |       return; | ||||||
| @@ -635,26 +648,12 @@ const groupedUsedIngredients = computed(() => { | |||||||
|  |  | ||||||
|     // Use the section title from the mapping, or fallback to the ingredient's own title |     // Use the section title from the mapping, or fallback to the ingredient's own title | ||||||
|     const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || ""; |     const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || ""; | ||||||
|  |     (groups[title] ||= []).push(ingredient); | ||||||
|     if (!groups[title]) { |  | ||||||
|       groups[title] = []; |  | ||||||
|     } |  | ||||||
|     groups[title].push(ingredient); |  | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   return groups; |   return groups; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| function getIngredientByRefId(refId: string | undefined) { |  | ||||||
|   if (refId === undefined) { |  | ||||||
|     return ""; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const ing = ingredientLookup.value[refId]; |  | ||||||
|   if (!ing) return ""; |  | ||||||
|   return parseIngredientText(ing, props.scale); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // =============================================================== | // =============================================================== | ||||||
| // Instruction Merger | // Instruction Merger | ||||||
| const mergeHistory = ref<MergerHistory[]>([]); | const mergeHistory = ref<MergerHistory[]>([]); | ||||||
| @@ -847,7 +846,21 @@ function openImageUpload(index: number) { | |||||||
|   z-index: 1; |   z-index: 1; | ||||||
| } | } | ||||||
|  |  | ||||||
| .v-text-field >>> input { | .v-text-field :deep(input) { | ||||||
|   font-size: 1.5rem; |   font-size: 1.5rem; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .recipe-step-title { | ||||||
|  |   /* Multiline display */ | ||||||
|  |   white-space: normal; | ||||||
|  |   line-height: 1.25; | ||||||
|  |   word-break: break-word; | ||||||
|  | } | ||||||
|  | .summary-wrapper { | ||||||
|  |   flex: 1 1 auto; | ||||||
|  |   min-width: 0; /* wrapping in flex container */ | ||||||
|  |   white-space: normal; | ||||||
|  |   overflow-wrap: anywhere; | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user