fix: ingredient linker and instructions titles (#6146)

Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
Arsène Reymond
2025-09-19 19:38:29 +02:00
committed by GitHub
parent 7623b72c4c
commit f6a1b5f4eb
3 changed files with 121 additions and 79 deletions

View File

@@ -37,7 +37,7 @@
} }
.handle { .handle {
cursor: grab; cursor: grab !important;
} }
.hidden { .hidden {

View File

@@ -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>

View File

@@ -45,7 +45,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>
@@ -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
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> </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,
}" }"
/> />
<div
v-if="step.ingredientReferences && step.ingredientReferences.length"
class="linked-ingredients-editor"
>
<div
v-for="(linkRef, i) in step.ingredientReferences"
:key="linkRef.referenceId ?? i"
class="mb-1"
>
<RecipeIngredientHtml <RecipeIngredientHtml
v-for="ing in step.ingredientReferences" v-if="linkRef.referenceId && ingredientLookup[linkRef.referenceId]"
:key="ing.referenceId!" :ingredient="ingredientLookup[linkRef.referenceId]"
:markup="getIngredientByRefId(ing.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>