mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-02 20:15:24 +02:00
feat: Structured Yields (#4489)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
parent
c8cd68b4f0
commit
327da02fc8
39 changed files with 1018 additions and 551 deletions
|
@ -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" });
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
69
frontend/components/Domain/Recipe/RecipeYield.vue
Normal file
69
frontend/components/Domain/Recipe/RecipeYield.vue
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue