1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-05 05:25:26 +02:00

feat: Structured Yields (#4489)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-11-20 08:46:27 -06:00 committed by GitHub
parent c8cd68b4f0
commit 327da02fc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1018 additions and 551 deletions

View file

@ -63,6 +63,8 @@ interface ShowHeaders {
tags: boolean;
categories: boolean;
tools: boolean;
recipeServings: boolean;
recipeYieldQuantity: boolean;
recipeYield: boolean;
dateAdded: boolean;
}
@ -93,6 +95,8 @@ export default defineComponent({
owner: false,
tags: true,
categories: true,
recipeServings: true,
recipeYieldQuantity: true,
recipeYield: true,
dateAdded: true,
};
@ -127,8 +131,14 @@ export default defineComponent({
if (props.showHeaders.tools) {
hdrs.push({ text: i18n.t("tool.tools"), value: "tools" });
}
if (props.showHeaders.recipeServings) {
hdrs.push({ text: i18n.t("recipe.servings"), value: "recipeServings" });
}
if (props.showHeaders.recipeYieldQuantity) {
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYieldQuantity" });
}
if (props.showHeaders.recipeYield) {
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYield" });
hdrs.push({ text: i18n.t("recipe.yield-text"), value: "recipeYield" });
}
if (props.showHeaders.dateAdded) {
hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" });

View file

@ -86,12 +86,6 @@
</BaseDialog>
</div>
<div>
<div class="d-flex justify-center flex-wrap">
<BaseButton :small="$vuetify.breakpoint.smAndDown" @click="madeThisDialog = true">
<template #icon> {{ $globals.icons.chefHat }} </template>
{{ $t('recipe.made-this') }}
</BaseButton>
</div>
<div class="d-flex justify-center flex-wrap">
<v-chip
label
@ -105,6 +99,12 @@
{{ $t('recipe.last-made-date', { date: value ? new Date(value).toLocaleDateString($i18n.locale) : $t("general.never") } ) }}
</v-chip>
</div>
<div class="d-flex justify-center flex-wrap mt-1">
<BaseButton :small="$vuetify.breakpoint.smAndDown" @click="madeThisDialog = true">
<template #icon> {{ $globals.icons.chefHat }} </template>
{{ $t('recipe.made-this') }}
</BaseButton>
</div>
</div>
</div>
</template>
@ -125,7 +125,7 @@ export default defineComponent({
},
recipe: {
type: Object as () => Recipe,
default: null,
required: true,
},
},
setup(props, context) {

View file

@ -1,7 +1,7 @@
<template>
<div>
<v-container v-show="!isCookMode" key="recipe-page" :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
<RecipePageHeader
:recipe="recipe"
:recipe-scale="scale"
@ -21,10 +21,10 @@
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
data management and mutation system we're using.
-->
<RecipePageInfoEditor v-if="isEditMode" :recipe="recipe" :landscape="landscape" />
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" />
<RecipePageTitleContent :recipe="recipe" :landscape="landscape" />
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape" />
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
<!--
This section contains the 2 column layout for the recipe steps and other content.
@ -76,7 +76,7 @@
<v-row style="height: 100%;" no-gutters class="overflow-hidden">
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%;">
<div class="d-flex align-center">
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape" />
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
</div>
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" />
<v-divider></v-divider>
@ -95,7 +95,7 @@
</v-sheet>
<v-sheet v-show="isCookMode && hasLinkedIngredients">
<div class="mt-2 px-2 px-md-4">
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape"/>
<RecipePageScale :recipe="recipe" :scale.sync="scale"/>
</div>
<RecipePageInstructions
v-model="recipe.recipeInstructions"
@ -154,7 +154,7 @@ import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredien
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
import RecipePageTitleContent from "./RecipePageParts/RecipePageTitleContent.vue";
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
@ -185,7 +185,7 @@ export default defineComponent({
RecipePageHeader,
RecipePrintContainer,
RecipePageComments,
RecipePageTitleContent,
RecipePageInfoEditor,
RecipePageEditorToolbar,
RecipePageIngredientEditor,
RecipePageOrganizers,
@ -195,7 +195,7 @@ export default defineComponent({
RecipeNotes,
RecipePageInstructions,
RecipePageFooter,
RecipeIngredients
RecipeIngredients,
},
props: {
recipe: {

View file

@ -1,46 +1,7 @@
<template>
<div>
<div class="d-flex justify-end flex-wrap align-stretch">
<v-card v-if="!landscape" width="50%" flat class="d-flex flex-column justify-center align-center">
<v-card-text>
<v-card-title class="headline pa-0 flex-column align-center">
{{ recipe.name }}
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
</v-card-title>
<v-divider class="my-2"></v-divider>
<SafeMarkdown :source="recipe.description" />
<v-divider></v-divider>
<div v-if="isOwnGroup" class="d-flex justify-center mt-5">
<RecipeLastMade
v-model="recipe.lastMade"
:recipe="recipe"
class="d-flex justify-center flex-wrap"
:class="true ? undefined : 'force-bottom'"
/>
</div>
<div class="d-flex justify-center mt-5">
<RecipeTimeCard
class="d-flex justify-center flex-wrap"
:class="true ? undefined : 'force-bottom'"
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
/>
</div>
</v-card-text>
</v-card>
<v-img
:key="imageKey"
:max-width="landscape ? null : '50%'"
min-height="50"
:height="hideImage ? undefined : imageHeight"
:src="recipeImageUrl"
class="d-print-none"
@error="hideImage = true"
>
</v-img>
</div>
<v-divider></v-divider>
<RecipePageInfoCard :recipe="recipe" :recipe-scale="recipeScale" :landscape="landscape" />
<v-divider />
<RecipeActionMenu
:recipe="recipe"
:slug="recipe.slug"
@ -65,10 +26,8 @@
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useRecipePermissions } from "~/composables/recipes";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { useStaticRoutes, useUserApi } from "~/composables/api";
import { HouseholdSummary } from "~/lib/api/types/household";
import { Recipe } from "~/lib/api/types/recipe";
@ -76,10 +35,8 @@ import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
export default defineComponent({
components: {
RecipeTimeCard,
RecipePageInfoCard,
RecipeActionMenu,
RecipeRating,
RecipeLastMade,
},
props: {
recipe: {

View file

@ -0,0 +1,101 @@
<template>
<div>
<div class="d-flex justify-end flex-wrap align-stretch">
<RecipePageInfoCardImage v-if="landscape" :recipe="recipe" />
<v-card
:width="landscape ? '100%' : '50%'"
flat
class="d-flex flex-column justify-center align-center"
>
<v-card-text>
<v-card-title class="headline pa-0 flex-column align-center">
{{ recipe.name }}
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
</v-card-title>
<v-divider class="my-2" />
<SafeMarkdown :source="recipe.description" />
<v-divider />
<v-container class="d-flex flex-row flex-wrap justify-center align-center">
<div class="mx-5">
<v-row no-gutters class="mb-1">
<v-col v-if="recipe.recipeYieldQuantity || recipe.recipeYield" cols="12" class="d-flex flex-wrap justify-center">
<RecipeYield
:yield-quantity="recipe.recipeYieldQuantity"
:yield="recipe.recipeYield"
:scale="recipeScale"
/>
</v-col>
</v-row>
<v-row no-gutters>
<v-col cols="12" class="d-flex flex-wrap justify-center">
<RecipeLastMade
v-if="isOwnGroup"
:value="recipe.lastMade"
:recipe="recipe"
:class="true ? undefined : 'force-bottom'"
/>
</v-col>
</v-row>
</div>
<div class="mx-5">
<RecipeTimeCard
stacked
container-class="d-flex flex-wrap justify-center"
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
/>
</div>
</v-container>
</v-card-text>
</v-card>
<RecipePageInfoCardImage v-if="!landscape" :recipe="recipe" max-width="50%" class="my-auto" />
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import RecipeYield from "~/components/Domain/Recipe/RecipeYield.vue";
import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue";
import { Recipe } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineComponent({
components: {
RecipeRating,
RecipeLastMade,
RecipeTimeCard,
RecipeYield,
RecipePageInfoCardImage,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
recipeScale: {
type: Number,
default: 1,
},
landscape: {
type: Boolean,
required: true,
},
},
setup() {
const { $vuetify } = useContext();
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
const { isOwnGroup } = useLoggedInState();
return {
isOwnGroup,
useMobile,
};
}
});
</script>

View file

@ -0,0 +1,69 @@
<template>
<v-img
:key="imageKey"
:max-width="maxWidth"
min-height="50"
:height="hideImage ? undefined : imageHeight"
:src="recipeImageUrl"
class="d-print-none"
@error="hideImage = true"
/>
</template>
<script lang="ts">
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api";
import { HouseholdSummary } from "~/lib/api/types/household";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { Recipe } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineComponent({
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
maxWidth: {
type: String,
default: undefined,
},
},
setup(props) {
const { $vuetify } = useContext();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}
const hideImage = ref(false);
const imageHeight = computed(() => {
return $vuetify.breakpoint.xs ? "200" : "400";
});
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
watch(
() => recipeImageUrl.value,
() => {
hideImage.value = false;
}
);
return {
recipeImageUrl,
imageKey,
hideImage,
imageHeight,
};
}
});
</script>

View file

@ -0,0 +1,107 @@
<template>
<div>
<v-text-field
v-model="recipe.name"
class="my-3"
:label="$t('recipe.recipe-name')"
:rules="[validators.required]"
/>
<v-container class="ma-0 pa-0">
<v-row>
<v-col cols="3">
<v-text-field
v-model="recipeServings"
type="number"
:min="0"
hide-spin-buttons
dense
:label="$t('recipe.servings')"
@input="validateInput($event, 'recipeServings')"
/>
</v-col>
<v-col cols="3">
<v-text-field
v-model="recipeYieldQuantity"
type="number"
:min="0"
hide-spin-buttons
dense
:label="$t('recipe.yield')"
@input="validateInput($event, 'recipeYieldQuantity')"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="recipe.recipeYield"
dense
:label="$t('recipe.yield-text')"
/>
</v-col>
</v-row>
</v-container>
<div class="d-flex flex-wrap" style="gap: 1rem">
<v-text-field v-model="recipe.totalTime" :label="$t('recipe.total-time')" />
<v-text-field v-model="recipe.prepTime" :label="$t('recipe.prep-time')" />
<v-text-field v-model="recipe.performTime" :label="$t('recipe.perform-time')" />
</div>
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" />
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { validators } from "~/composables/use-validators";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
export default defineComponent({
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const recipeServings = computed<number>({
get() {
return props.recipe.recipeServings;
},
set(val) {
validateInput(val.toString(), "recipeServings");
},
});
const recipeYieldQuantity = computed<number>({
get() {
return props.recipe.recipeYieldQuantity;
},
set(val) {
validateInput(val.toString(), "recipeYieldQuantity");
},
});
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
if (!value) {
props.recipe[property] = 0;
return;
}
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
if (isNaN(number) || number <= 0) {
props.recipe[property] = 0;
return;
}
props.recipe[property] = number;
}
return {
validators,
recipeServings,
recipeYieldQuantity,
validateInput,
};
},
});
</script>

View file

@ -5,50 +5,32 @@
<RecipeScaleEditButton
v-model.number="scaleValue"
v-bind="attrs"
:recipe-yield="recipe.recipeYield"
:scaled-yield="scaledYield"
:basic-yield-num="basicYieldNum"
:recipe-servings="recipeServings"
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
v-on="on"
/>
</template>
<span> {{ $t("recipe.edit-scale") }} </span>
</v-tooltip>
<v-spacer></v-spacer>
<RecipeRating
v-if="landscape && $vuetify.breakpoint.smAndUp"
:key="recipe.slug"
v-model="recipe.rating"
:recipe-id="recipe.id"
:slug="recipe.slug"
/>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
import { computed, defineComponent } from "@nuxtjs/composition-api";
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { useExtractRecipeYield, findMatch } from "~/composables/recipe-page/use-extract-recipe-yield";
export default defineComponent({
components: {
RecipeScaleEditButton,
RecipeRating,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
landscape: {
type: Boolean,
default: false,
},
scale: {
type: Number,
default: 1,
@ -57,6 +39,10 @@ export default defineComponent({
setup(props, { emit }) {
const { isEditMode } = usePageState(props.recipe.slug);
const recipeServings = computed<number>(() => {
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
});
const scaleValue = computed<number>({
get() {
return props.scale;
@ -66,17 +52,9 @@ export default defineComponent({
},
});
const scaledYield = computed(() => {
return useExtractRecipeYield(props.recipe.recipeYield, scaleValue.value);
});
const match = findMatch(props.recipe.recipeYield);
const basicYieldNum = ref<number |null>(match ? match[1] : null);
return {
recipeServings,
scaleValue,
scaledYield,
basicYieldNum,
isEditMode,
};
},

View file

@ -1,92 +0,0 @@
<template>
<div>
<template v-if="!isEditMode && landscape">
<v-card-title class="px-0 py-2 ma-0 headline">
{{ recipe.name }}
</v-card-title>
<SafeMarkdown :source="recipe.description" />
<div v-if="isOwnGroup" class="pb-2 d-flex justify-center flex-wrap">
<RecipeLastMade
v-model="recipe.lastMade"
:recipe="recipe"
class="d-flex justify-center flex-wrap"
:class="true ? undefined : 'force-bottom'"
/>
</div>
<div class="pb-2 d-flex justify-center flex-wrap">
<RecipeTimeCard
class="d-flex justify-center flex-wrap"
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
/>
<RecipeRating
v-if="$vuetify.breakpoint.smAndDown"
:key="recipe.slug"
v-model="recipe.rating"
:recipe-id="recipe.id"
:slug="recipe.slug"
/>
</div>
<v-divider></v-divider>
</template>
<template v-else-if="isEditMode">
<v-text-field
v-model="recipe.name"
class="my-3"
:label="$t('recipe.recipe-name')"
:rules="[validators.required]"
/>
<v-text-field v-model="recipe.recipeYield" dense :label="$t('recipe.servings')" />
<div class="d-flex flex-wrap" style="gap: 1rem">
<v-text-field v-model="recipe.totalTime" :label="$t('recipe.total-time')" />
<v-text-field v-model="recipe.prepTime" :label="$t('recipe.prep-time')" />
<v-text-field v-model="recipe.performTime" :label="$t('recipe.perform-time')" />
</div>
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" />
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { validators } from "~/composables/use-validators";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
export default defineComponent({
components: {
RecipeRating,
RecipeTimeCard,
RecipeLastMade,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
landscape: {
type: Boolean,
default: false,
},
},
setup(props) {
const { user } = usePageUser();
const { imageKey, isEditMode } = usePageState(props.recipe.slug);
const { isOwnGroup } = useLoggedInState();
return {
user,
imageKey,
validators,
isEditMode,
isOwnGroup,
};
},
});
</script>

View file

@ -18,7 +18,24 @@
</v-icon>
{{ recipe.name }}
</v-card-title>
<RecipeTimeCard :prep-time="recipe.prepTime" :total-time="recipe.totalTime" :perform-time="recipe.performTime" color="white" />
<div v-if="recipeYield" class="d-flex justify-space-between align-center px-4 pb-2">
<v-chip
:small="$vuetify.breakpoint.smAndDown"
label
>
<v-icon left>
{{ $globals.icons.potSteam }}
</v-icon>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="recipeYield"></span>
</v-chip>
</div>
<RecipeTimeCard
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
color="white"
/>
<v-card-text v-if="preferences.showDescription" class="px-0">
<SafeMarkdown :source="recipe.description" />
</v-card-text>
@ -30,9 +47,6 @@
<!-- Ingredients -->
<section>
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
<div class="font-italic px-0 py-0">
<SafeMarkdown :source="recipe.recipeYield" />
</div>
<div
v-for="(ingredientSection, sectionIndex) in ingredientSections"
:key="`ingredient-section-${sectionIndex}`"
@ -111,7 +125,8 @@
</template>
<script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import DOMPurify from "dompurify";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { useStaticRoutes } from "~/composables/api";
import { Recipe, RecipeIngredient, RecipeStep} from "~/lib/api/types/recipe";
@ -119,6 +134,7 @@ import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
type IngredientSection = {
@ -151,13 +167,39 @@ export default defineComponent({
}
},
setup(props) {
const { i18n } = useContext();
const preferences = useUserPrintPreferences();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const {labels} = useNutritionLabels();
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
const servingsDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
return scaledAmountDisplay ? i18n.t("recipe.yields-amount-with-text", {
amount: scaledAmountDisplay,
text: props.recipe.recipeYield,
}) as string : "";
})
const yieldDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
});
const recipeYield = computed(() => {
if (servingsDisplay.value && yieldDisplay.value) {
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
} else {
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
}
})
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
@ -258,6 +300,7 @@ export default defineComponent({
parseIngredientText,
preferences,
recipeImageUrl,
recipeYield,
ingredientSections,
instructionSections,
};

View file

@ -1,16 +1,13 @@
<template>
<div>
<div v-if="yieldDisplay">
<div class="text-center d-flex align-center">
<div>
<v-menu v-model="menu" :disabled="!editScale" offset-y top nudge-top="6" :close-on-content-click="false">
<v-menu v-model="menu" :disabled="!canEditScale" offset-y top nudge-top="6" :close-on-content-click="false">
<template #activator="{ on, attrs }">
<v-card class="pa-1 px-2" dark color="secondary darken-1" small v-bind="attrs" v-on="on">
<span v-if="!recipeYield"> x {{ scale }} </span>
<div v-else-if="!numberParsed && recipeYield">
<span v-if="numerator === 1"> {{ recipeYield }} </span>
<span v-else> {{ numerator }}x {{ scaledYield }} </span>
</div>
<span v-else> {{ scaledYield }} </span>
<v-icon small class="mr-2">{{ $globals.icons.edit }}</v-icon>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="yieldDisplay"></span>
</v-card>
</template>
@ -20,7 +17,7 @@
</v-card-title>
<v-card-text class="mt-n5">
<div class="mt-4 d-flex align-center">
<v-text-field v-model="numerator" type="number" :min="0" hide-spin-buttons />
<v-text-field v-model="yieldQuantityEditorValue" type="number" :min="0" hide-spin-buttons @input="recalculateScale(yieldQuantityEditorValue)" />
<v-tooltip right color="secondary darken-1">
<template #activator="{ on, attrs }">
<v-btn v-bind="attrs" icon class="mx-1" small v-on="on" @click="scale = 1">
@ -37,7 +34,7 @@
</v-menu>
</div>
<BaseButtonGroup
v-if="editScale"
v-if="canEditScale"
class="pl-2"
:large="false"
:buttons="[
@ -53,41 +50,36 @@
event: 'increment',
},
]"
@decrement="numerator--"
@increment="numerator++"
@decrement="recalculateScale(yieldQuantity - 1)"
@increment="recalculateScale(yieldQuantity + 1)"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch } from "@nuxtjs/composition-api";
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
export default defineComponent({
props: {
recipeYield: {
type: String,
default: null,
},
scaledYield: {
type: String,
default: null,
},
basicYieldNum: {
value: {
type: Number,
default: null,
required: true,
},
recipeServings: {
type: Number,
default: 0,
},
editScale: {
type: Boolean,
default: false,
},
value: {
type: Number,
required: true,
},
},
setup(props, { emit }) {
const { i18n } = useContext();
const menu = ref<boolean>(false);
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
const scale = computed({
get: () => props.value,
@ -97,24 +89,54 @@ export default defineComponent({
},
});
const numerator = ref<number>(props.basicYieldNum != null ? parseFloat(props.basicYieldNum.toFixed(3)) : 1);
const denominator = props.basicYieldNum != null ? parseFloat(props.basicYieldNum.toFixed(32)) : 1;
const numberParsed = !!props.basicYieldNum;
function recalculateScale(newYield: number) {
if (isNaN(newYield) || newYield <= 0) {
return;
}
watch(() => numerator.value, () => {
scale.value = parseFloat((numerator.value / denominator).toFixed(32));
if (props.recipeServings <= 0) {
scale.value = 1;
} else {
scale.value = newYield / props.recipeServings;
}
}
const recipeYieldAmount = computed(() => {
return useScaledAmount(props.recipeServings, scale.value);
});
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
const yieldDisplay = computed(() => {
return yieldQuantity.value ? i18n.t(
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay }
) as string : "";
});
// only update yield quantity when the menu opens, so we don't override the user's input
const yieldQuantityEditorValue = ref(recipeYieldAmount.value.scaledAmount);
watch(
() => menu.value,
() => {
if (!menu.value) {
return;
}
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
}
)
const disableDecrement = computed(() => {
return numerator.value <= 1;
return recipeYieldAmount.value.scaledAmount <= 1;
});
return {
menu,
canEditScale,
scale,
numerator,
recalculateScale,
yieldDisplay,
yieldQuantity,
yieldQuantityEditorValue,
disableDecrement,
numberParsed,
};
},
});

View file

@ -1,19 +1,41 @@
<template>
<div>
<v-chip
v-for="(time, index) in allTimes"
:key="index"
:small="$vuetify.breakpoint.smAndDown"
label
:color="color"
class="ma-1"
>
<v-icon left>
{{ $globals.icons.clockOutline }}
</v-icon>
{{ time.name }} |
{{ time.value }}
</v-chip>
<div v-if="stacked">
<v-container>
<v-row v-for="(time, index) in allTimes" :key="`${index}-stacked`" no-gutters>
<v-col cols="12" :class="containerClass">
<v-chip
:small="$vuetify.breakpoint.smAndDown"
label
:color="color"
class="ma-1"
>
<v-icon left>
{{ $globals.icons.clockOutline }}
</v-icon>
{{ time.name }} |
{{ time.value }}
</v-chip>
</v-col>
</v-row>
</v-container>
</div>
<div v-else>
<v-container :class="containerClass">
<v-chip
v-for="(time, index) in allTimes"
:key="index"
:small="$vuetify.breakpoint.smAndDown"
label
:color="color"
class="ma-1"
>
<v-icon left>
{{ $globals.icons.clockOutline }}
</v-icon>
{{ time.name }} |
{{ time.value }}
</v-chip>
</v-container>
</div>
</template>
@ -22,6 +44,10 @@ import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
export default defineComponent({
props: {
stacked: {
type: Boolean,
default: false,
},
prepTime: {
type: String,
default: null,
@ -38,6 +64,10 @@ export default defineComponent({
type: String,
default: "accent custom-transparent"
},
containerClass: {
type: String,
default: undefined,
},
},
setup(props) {
const { i18n } = useContext();

View file

@ -0,0 +1,69 @@
<template>
<div v-if="displayText" class="d-flex justify-space-between align-center">
<v-chip
:small="$vuetify.breakpoint.smAndDown"
label
:color="color"
>
<v-icon left>
{{ $globals.icons.potSteam }}
</v-icon>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="displayText"></span>
</v-chip>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import DOMPurify from "dompurify";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
export default defineComponent({
props: {
yieldQuantity: {
type: Number,
default: 0,
},
yield: {
type: String,
default: "",
},
scale: {
type: Number,
default: 1,
},
color: {
type: String,
default: "accent custom-transparent"
},
},
setup(props) {
const { i18n } = useContext();
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
const displayText = computed(() => {
if (!(props.yieldQuantity || props.yield)) {
return "";
}
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
return i18n.t("recipe.yields-amount-with-text", {
amount: scaledAmountDisplay,
text: sanitizeHTML(props.yield),
}) as string;
});
return {
displayText,
};
},
});
</script>

View file

@ -1,111 +0,0 @@
import { describe, expect, test } from "vitest";
import { useExtractRecipeYield } from "./use-extract-recipe-yield";
describe("test use extract recipe yield", () => {
test("when text empty return empty", () => {
const result = useExtractRecipeYield(null, 1);
expect(result).toStrictEqual("");
});
test("when text matches nothing return text", () => {
const val = "this won't match anything";
const result = useExtractRecipeYield(val, 1);
expect(result).toStrictEqual(val);
const resultScaled = useExtractRecipeYield(val, 5);
expect(resultScaled).toStrictEqual(val);
});
test("when text matches a mixed fraction, return a scaled fraction", () => {
const val = "10 1/2 units";
const result = useExtractRecipeYield(val, 1);
expect(result).toStrictEqual(val);
const resultScaled = useExtractRecipeYield(val, 3);
expect(resultScaled).toStrictEqual("31 1/2 units");
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
expect(resultScaledPartial).toStrictEqual("26 1/4 units");
const resultScaledInt = useExtractRecipeYield(val, 4);
expect(resultScaledInt).toStrictEqual("42 units");
});
test("when text matches a fraction, return a scaled fraction", () => {
const val = "1/3 plates";
const result = useExtractRecipeYield(val, 1);
expect(result).toStrictEqual(val);
const resultScaled = useExtractRecipeYield(val, 2);
expect(resultScaled).toStrictEqual("2/3 plates");
const resultScaledInt = useExtractRecipeYield(val, 3);
expect(resultScaledInt).toStrictEqual("1 plates");
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
expect(resultScaledPartial).toStrictEqual("5/6 plates");
const resultScaledMixed = useExtractRecipeYield(val, 4);
expect(resultScaledMixed).toStrictEqual("1 1/3 plates");
});
test("when text matches a decimal, return a scaled, rounded decimal", () => {
const val = "1.25 parts";
const result = useExtractRecipeYield(val, 1);
expect(result).toStrictEqual(val);
const resultScaled = useExtractRecipeYield(val, 2);
expect(resultScaled).toStrictEqual("2.5 parts");
const resultScaledInt = useExtractRecipeYield(val, 4);
expect(resultScaledInt).toStrictEqual("5 parts");
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
expect(resultScaledPartial).toStrictEqual("3.125 parts");
const roundedVal = "1.33333333333333333333 parts";
const resultScaledRounded = useExtractRecipeYield(roundedVal, 2);
expect(resultScaledRounded).toStrictEqual("2.667 parts");
});
test("when text matches an int, return a scaled int", () => {
const val = "5 bowls";
const result = useExtractRecipeYield(val, 1);
expect(result).toStrictEqual(val);
const resultScaled = useExtractRecipeYield(val, 2);
expect(resultScaled).toStrictEqual("10 bowls");
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
expect(resultScaledPartial).toStrictEqual("12.5 bowls");
const resultScaledLarge = useExtractRecipeYield(val, 10);
expect(resultScaledLarge).toStrictEqual("50 bowls");
});
test("when text contains an invalid fraction, return the original string", () => {
const valDivZero = "3/0 servings";
const resultDivZero = useExtractRecipeYield(valDivZero, 3);
expect(resultDivZero).toStrictEqual(valDivZero);
const valDivZeroMixed = "2 4/0 servings";
const resultDivZeroMixed = useExtractRecipeYield(valDivZeroMixed, 6);
expect(resultDivZeroMixed).toStrictEqual(valDivZeroMixed);
});
test("when text contains a weird or small fraction, return the original string", () => {
const valWeird = "2323231239087/134527431962272135 servings";
const resultWeird = useExtractRecipeYield(valWeird, 5);
expect(resultWeird).toStrictEqual(valWeird);
const valSmall = "1/20230225 lovable servings";
const resultSmall = useExtractRecipeYield(valSmall, 12);
expect(resultSmall).toStrictEqual(valSmall);
});
test("when text contains multiple numbers, the first is parsed as the servings amount", () => {
const val = "100 sets of 55 bowls";
const result = useExtractRecipeYield(val, 3);
expect(result).toStrictEqual("300 sets of 55 bowls");
})
});

View file

@ -1,132 +0,0 @@
import { useFraction } from "~/composables/recipes";
const matchMixedFraction = /(?:\d*\s\d*\d*|0)\/\d*\d*/;
const matchFraction = /(?:\d*\d*|0)\/\d*\d*/;
const matchDecimal = /(\d+.\d+)|(.\d+)/;
const matchInt = /\d+/;
function extractServingsFromMixedFraction(fractionString: string): number | undefined {
const mixedSplit = fractionString.split(/\s/);
const wholeNumber = parseInt(mixedSplit[0]);
const fraction = mixedSplit[1];
const fractionSplit = fraction.split("/");
const numerator = parseInt(fractionSplit[0]);
const denominator = parseInt(fractionSplit[1]);
if (denominator === 0) {
return undefined; // if the denominator is zero, just give up
}
else {
return wholeNumber + (numerator / denominator);
}
}
function extractServingsFromFraction(fractionString: string): number | undefined {
const fractionSplit = fractionString.split("/");
const numerator = parseInt(fractionSplit[0]);
const denominator = parseInt(fractionSplit[1]);
if (denominator === 0) {
return undefined; // if the denominator is zero, just give up
}
else {
return numerator / denominator;
}
}
export function findMatch(yieldString: string): [matchString: string, servings: number, isFraction: boolean] | null {
if (!yieldString) {
return null;
}
const mixedFractionMatch = yieldString.match(matchMixedFraction);
if (mixedFractionMatch?.length) {
const match = mixedFractionMatch[0];
const servings = extractServingsFromMixedFraction(match);
// if the denominator is zero, return no match
if (servings === undefined) {
return null;
} else {
return [match, servings, true];
}
}
const fractionMatch = yieldString.match(matchFraction);
if (fractionMatch?.length) {
const match = fractionMatch[0]
const servings = extractServingsFromFraction(match);
// if the denominator is zero, return no match
if (servings === undefined) {
return null;
} else {
return [match, servings, true];
}
}
const decimalMatch = yieldString.match(matchDecimal);
if (decimalMatch?.length) {
const match = decimalMatch[0];
return [match, parseFloat(match), false];
}
const intMatch = yieldString.match(matchInt);
if (intMatch?.length) {
const match = intMatch[0];
return [match, parseInt(match), false];
}
return null;
}
function formatServings(servings: number, scale: number, isFraction: boolean): string {
const val = servings * scale;
if (Number.isInteger(val)) {
return val.toString();
} else if (!isFraction) {
return (Math.round(val * 1000) / 1000).toString();
}
// convert val into a fraction string
const { frac } = useFraction();
let valString = "";
const fraction = frac(val, 10, true);
if (fraction[0] !== undefined && fraction[0] > 0) {
valString += fraction[0];
}
if (fraction[1] > 0) {
valString += ` ${fraction[1]}/${fraction[2]}`;
}
return valString.trim();
}
export function useExtractRecipeYield(yieldString: string | null, scale: number): string {
if (!yieldString) {
return "";
}
const match = findMatch(yieldString);
if (!match) {
return yieldString;
}
const [matchString, servings, isFraction] = match;
const formattedServings = formatServings(servings, scale, isFraction);
if (!formattedServings) {
return yieldString // this only happens with very weird or small fractions
} else {
return yieldString.replace(matchString, formatServings(servings, scale, isFraction));
}
}

View file

@ -0,0 +1,68 @@
import { describe, expect, test } from "vitest";
import { useScaledAmount } from "./use-scaled-amount";
describe("test use recipe yield", () => {
function asFrac(numerator: number, denominator: number): string {
return `<sup>${numerator}</sup><span>&frasl;</span><sub>${denominator}</sub>`;
}
test("base case", () => {
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3);
expect(scaledAmount).toStrictEqual(3);
expect(scaledAmountDisplay).toStrictEqual("3");
});
test("base case scaled", () => {
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3, 2);
expect(scaledAmount).toStrictEqual(6);
expect(scaledAmountDisplay).toStrictEqual("6");
});
test("zero scale", () => {
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3, 0);
expect(scaledAmount).toStrictEqual(0);
expect(scaledAmountDisplay).toStrictEqual("");
});
test("zero quantity", () => {
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0);
expect(scaledAmount).toStrictEqual(0);
expect(scaledAmountDisplay).toStrictEqual("");
});
test("basic fraction", () => {
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0.5);
expect(scaledAmount).toStrictEqual(0.5);
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 2));
});
test("mixed fraction", () => {
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.5);
expect(scaledAmount).toStrictEqual(1.5);
expect(scaledAmountDisplay).toStrictEqual(`1${asFrac(1, 2)}`);
});
test("mixed fraction scaled", () => {
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.5, 9);
expect(scaledAmount).toStrictEqual(13.5);
expect(scaledAmountDisplay).toStrictEqual(`13${asFrac(1, 2)}`);
});
test("small scale", () => {
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1, 0.125);
expect(scaledAmount).toStrictEqual(0.125);
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 8));
});
test("small qty", () => {
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0.125);
expect(scaledAmount).toStrictEqual(0.125);
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 8));
});
test("rounded decimal", () => {
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.3344559997);
expect(scaledAmount).toStrictEqual(1.334);
expect(scaledAmountDisplay).toStrictEqual(`1${asFrac(1, 3)}`);
});
});

View file

@ -0,0 +1,32 @@
import { useFraction } from "~/composables/recipes";
function formatQuantity(val: number): string {
if (Number.isInteger(val)) {
return val.toString();
}
const { frac } = useFraction();
let valString = "";
const fraction = frac(val, 10, true);
if (fraction[0] !== undefined && fraction[0] > 0) {
valString += fraction[0];
}
if (fraction[1] > 0) {
valString += `<sup>${fraction[1]}</sup><span>&frasl;</span><sub>${fraction[2]}</sub>`;
}
return valString.trim();
}
export function useScaledAmount(amount: number, scale = 1) {
const scaledAmount = Number(((amount || 0) * scale).toFixed(3));
const scaledAmountDisplay = scaledAmount ? formatQuantity(scaledAmount) : "";
return {
scaledAmount,
scaledAmountDisplay,
};
}

View file

@ -517,6 +517,7 @@
"save-recipe-before-use": "Save recipe before use",
"section-title": "Section Title",
"servings": "Servings",
"serves-amount": "Serves {amount}",
"share-recipe-message": "I wanted to share my {0} recipe with you.",
"show-nutrition-values": "Show Nutrition Values",
"sodium-content": "Sodium",
@ -545,6 +546,8 @@
"failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan",
"failed-to-add-to-list": "Failed to add to list",
"yield": "Yield",
"yields-amount-with-text": "Yields {amount} {text}",
"yield-text": "Yield Text",
"quantity": "Quantity",
"choose-unit": "Choose Unit",
"press-enter-to-create": "Press Enter to Create",
@ -640,7 +643,9 @@
"recipe-debugger-use-openai-description": "Use OpenAI to parse the results instead of relying on the scraper library. When creating a recipe via URL, this is done automatically if the scraper library fails, but you may test it manually here.",
"debug": "Debug",
"tree-view": "Tree View",
"recipe-servings": "Recipe Servings",
"recipe-yield": "Recipe Yield",
"recipe-yield-text": "Recipe Yield Text",
"unit": "Unit",
"upload-image": "Upload image",
"screen-awake": "Keep Screen Awake",

View file

@ -126,6 +126,8 @@ export interface RecipeSummary {
name?: string | null;
slug?: string;
image?: unknown;
recipeServings?: number;
recipeYieldQuantity?: number;
recipeYield?: string | null;
totalTime?: string | null;
prepTime?: string | null;

View file

@ -62,6 +62,8 @@ export interface RecipeSummary {
name?: string | null;
slug?: string;
image?: unknown;
recipeServings?: number;
recipeYieldQuantity?: number;
recipeYield?: string | null;
totalTime?: string | null;
prepTime?: string | null;

View file

@ -87,6 +87,8 @@ export interface RecipeSummary {
name?: string | null;
slug?: string;
image?: unknown;
recipeServings?: number;
recipeYieldQuantity?: number;
recipeYield?: string | null;
totalTime?: string | null;
prepTime?: string | null;

View file

@ -230,6 +230,8 @@ export interface Recipe {
name?: string | null;
slug?: string;
image?: unknown;
recipeServings?: number;
recipeYieldQuantity?: number;
recipeYield?: string | null;
totalTime?: string | null;
prepTime?: string | null;
@ -307,6 +309,8 @@ export interface RecipeSummary {
name?: string | null;
slug?: string;
image?: unknown;
recipeServings?: number;
recipeYieldQuantity?: number;
recipeYield?: string | null;
totalTime?: string | null;
prepTime?: string | null;

View file

@ -218,6 +218,8 @@ export default defineComponent({
tags: true,
tools: true,
categories: true,
recipeServings: false,
recipeYieldQuantity: false,
recipeYield: false,
dateAdded: false,
});
@ -228,7 +230,9 @@ export default defineComponent({
tags: i18n.t("tag.tags"),
categories: i18n.t("recipe.categories"),
tools: i18n.t("tool.tools"),
recipeYield: i18n.t("recipe.recipe-yield"),
recipeServings: i18n.t("recipe.recipe-servings"),
recipeYieldQuantity: i18n.t("recipe.recipe-yield"),
recipeYield: i18n.t("recipe.recipe-yield-text"),
dateAdded: i18n.t("general.date-added"),
};