1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-30 18:49:41 +02:00

feat: Added a dedicated cookmode dialog that allows for individual scrolling (#4464)

This commit is contained in:
Tarek Auf der Strasse 2024-11-11 12:21:44 +01:00 committed by GitHub
parent 65c35adc9d
commit d419acd61e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 190 additions and 87 deletions

View file

@ -1,14 +1,16 @@
<template> <template>
<div v-if="value && value.length > 0"> <div v-if="value && value.length > 0">
<div class="d-flex justify-start"> <div v-if="!isCookMode" class="d-flex justify-start" >
<h2 class="mb-2 mt-1">{{ $t("recipe.ingredients") }}</h2> <h2 class="mb-2 mt-1">{{ $t("recipe.ingredients") }}</h2>
<AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" /> <AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" />
</div> </div>
<div> <div>
<div v-for="(ingredient, index) in value" :key="'ingredient' + index"> <div v-for="(ingredient, index) in value" :key="'ingredient' + index">
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3> <template v-if="!isCookMode">
<v-divider v-if="showTitleEditor[index]"></v-divider> <h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
<v-list-item dense @click="toggleChecked(index)"> <v-divider v-if="showTitleEditor[index]"></v-divider>
</template>
<v-list-item dense @click.stop="toggleChecked(index)">
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" /> <v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
<v-list-item-content :key="ingredient.quantity"> <v-list-item-content :key="ingredient.quantity">
<RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" /> <RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" />
@ -40,6 +42,10 @@ export default defineComponent({
type: Number, type: Number,
default: 1, default: 1,
}, },
isCookMode: {
type: Boolean,
default: false,
}
}, },
setup(props) { setup(props) {
function validateTitle(title?: string) { function validateTitle(title?: string) {

View file

@ -1,75 +1,135 @@
<template> <template>
<v-container :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }"> <div>
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none"> <v-container v-show="!isCookMode" key="recipe-page" :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
<RecipePageHeader <v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
<RecipePageHeader
:recipe="recipe"
:recipe-scale="scale"
:landscape="landscape"
@save="saveRecipe"
@delete="deleteRecipe"
/>
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" />
<v-card-text v-else>
<!--
This is where most of the main content is rendered. Some components include state for both Edit and View modes
which is why some have explicit v-if statements and others use the composition API to determine and manage
the shared state internally.
The global recipe object is shared down the tree of components and _is_ mutated by child components. This is
some-what of a hack of the system and goes against the principles of Vue, but it _does_ seem to work and streamline
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.
-->
<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" />
<!--
This section contains the 2 column layout for the recipe steps and other content.
-->
<v-row>
<!--
The left column is conditionally rendered based on cook mode.
-->
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" />
</v-col>
<v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
<!--
the right column is always rendered, but it's layout width is determined by where the left column is
rendered.
-->
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
<RecipePageInstructions
v-model="recipe.recipeInstructions"
:assets.sync="recipe.assets"
:recipe="recipe"
:scale="scale"
/>
<div v-if="isEditForm" class="d-flex">
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.add") }}</BaseButton>
</div>
<div v-if="!$vuetify.breakpoint.mdAndUp">
<RecipePageOrganizers :recipe="recipe" />
</div>
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" />
</v-col>
</v-row>
<RecipePageFooter :recipe="recipe" />
</v-card-text>
</v-card>
<WakelockSwitch/>
<RecipePageComments
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
:recipe="recipe" :recipe="recipe"
:recipe-scale="scale" class="px-1 my-4 d-print-none"
:landscape="landscape"
@save="saveRecipe"
@delete="deleteRecipe"
/> />
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" /> <RecipePrintContainer :recipe="recipe" :scale="scale" />
<v-card-text v-else> </v-container>
<!-- <!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same timer -->
This is where most of the main content is rendered. Some components include state for both Edit and View modes <v-sheet v-show="isCookMode && !hasLinkedIngredients" key="cookmode" :style="{height: $vuetify.breakpoint.smAndUp ? 'calc(100vh - 48px)' : ''}"> <!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
which is why some have explicit v-if statements and others use the composition API to determine and manage <v-row style="height: 100%;" no-gutters class="overflow-hidden">
the shared state internally. <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" />
</div>
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" />
<v-divider></v-divider>
</v-col>
<v-col class="overflow-y-auto py-2" style="height: 100%;" cols="12" sm="7">
<RecipePageInstructions
v-model="recipe.recipeInstructions"
class="overflow-y-hidden px-4"
:assets.sync="recipe.assets"
:recipe="recipe"
:scale="scale"
/>
</v-col>
</v-row>
The global recipe object is shared down the tree of components and _is_ mutated by child components. This is </v-sheet>
some-what of a hack of the system and goes against the principles of Vue, but it _does_ seem to work and streamline <v-sheet v-show="isCookMode && hasLinkedIngredients">
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 <div class="mt-2 px-2 px-md-4">
data management and mutation system we're using. <RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape"/>
--> </div>
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" /> <RecipePageInstructions
<RecipePageTitleContent :recipe="recipe" :landscape="landscape" /> v-model="recipe.recipeInstructions"
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" /> class="overflow-y-hidden mt-n5 px-2 px-md-4"
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape" /> :assets.sync="recipe.assets"
:recipe="recipe"
<!-- :scale="scale"
This section contains the 2 column layout for the recipe steps and other content. />
--> <v-divider></v-divider>
<v-row> <div class="px-2 px-md-4 pb-4 ">
<!-- <v-card flat>
The left column is conditionally rendered based on cook mode. <v-card-title>{{ $t('recipe.not-linked-ingredients') }}</v-card-title>
--> <RecipeIngredients
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4"> :value="notLinkedIngredients"
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" />
</v-col>
<v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
<!--
the right column is always rendered, but it's layout width is determined by where the left column is
rendered.
-->
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
<RecipePageInstructions
v-model="recipe.recipeInstructions"
:assets.sync="recipe.assets"
:recipe="recipe"
:scale="scale" :scale="scale"
/> :disable-amount="recipe.settings.disableAmount"
<div v-if="isEditForm" class="d-flex"> :is-cook-mode="isCookMode">
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.add") }}</BaseButton> </RecipeIngredients>
</div> </v-card>
<div v-if="!$vuetify.breakpoint.mdAndUp"> </div>
<RecipePageOrganizers :recipe="recipe" /> </v-sheet>
</div> <v-btn
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" /> v-if="isCookMode"
</v-col> fab
</v-row> small
<RecipePageFooter :recipe="recipe" /> color="primary"
</v-card-text> style="position: fixed; right: 12px; top: 60px;"
</v-card> @click="toggleCookMode()"
<WakelockSwitch/> >
<RecipePageComments <v-icon>mdi-close</v-icon>
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode" </v-btn>
:recipe="recipe" </div>
class="px-1 my-4 d-print-none"
/>
<RecipePrintContainer :recipe="recipe" :scale="scale" />
</v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -84,6 +144,7 @@ import {
useRoute, useRoute,
} from "@nuxtjs/composition-api"; } from "@nuxtjs/composition-api";
import { invoke, until } from "@vueuse/core"; import { invoke, until } from "@vueuse/core";
import RecipeIngredients from "../RecipeIngredients.vue";
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue"; import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
import RecipePageFooter from "./RecipePageParts/RecipePageFooter.vue"; import RecipePageFooter from "./RecipePageParts/RecipePageFooter.vue";
import RecipePageHeader from "./RecipePageParts/RecipePageHeader.vue"; import RecipePageHeader from "./RecipePageParts/RecipePageHeader.vue";
@ -133,6 +194,7 @@ export default defineComponent({
RecipeNotes, RecipeNotes,
RecipePageInstructions, RecipePageInstructions,
RecipePageFooter, RecipePageFooter,
RecipeIngredients
}, },
props: { props: {
recipe: { recipe: {
@ -151,6 +213,13 @@ export default defineComponent({
const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } = const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } =
usePageState(props.recipe.slug); usePageState(props.recipe.slug);
const { deactivateNavigationWarning } = useNavigationWarning(); const { deactivateNavigationWarning } = useNavigationWarning();
const notLinkedIngredients = computed(() => {
console.log("inst",props.recipe.recipeInstruction);
return props.recipe.recipeIngredient.filter((ingredient) => {
return !props.recipe.recipeInstructions.some((step) => step.ingredientReferences?.map((ref) => ref.referenceId).includes(ingredient.referenceId));
})
})
console.log(notLinkedIngredients);
/** ============================================================= /** =============================================================
* Recipe Snapshot on Mount * Recipe Snapshot on Mount
@ -176,11 +245,14 @@ export default defineComponent({
} }
} }
deactivateNavigationWarning(); deactivateNavigationWarning();
toggleCookMode()
clearPageState(props.recipe.slug || ""); clearPageState(props.recipe.slug || "");
console.debug("reset RecipePage state during unmount"); console.debug("reset RecipePage state during unmount");
}); });
const hasLinkedIngredients = computed(() => {
return props.recipe.recipeInstructions.some((step) => step.ingredientReferences && step.ingredientReferences.length > 0);
})
/** ============================================================= /** =============================================================
* Set State onMounted * Set State onMounted
*/ */
@ -278,6 +350,8 @@ export default defineComponent({
saveRecipe, saveRecipe,
deleteRecipe, deleteRecipe,
addStep, addStep,
hasLinkedIngredients,
notLinkedIngredients
}; };
}, },
head: {}, head: {},

View file

@ -4,6 +4,7 @@
:value="recipe.recipeIngredient" :value="recipe.recipeIngredient"
:scale="scale" :scale="scale"
:disable-amount="recipe.settings.disableAmount" :disable-amount="recipe.settings.disableAmount"
:is-cook-mode="isCookMode"
/> />
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0"> <div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
<h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2> <h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2>
@ -46,6 +47,10 @@ export default defineComponent({
type: Number, type: Number,
required: true, required: true,
}, },
isCookMode: {
type: Boolean,
default: false,
}
}, },
setup(props) { setup(props) {
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();

View file

@ -65,8 +65,8 @@
</v-dialog> </v-dialog>
<div class="d-flex justify-space-between justify-start"> <div class="d-flex justify-space-between justify-start">
<h2 class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2> <h2 v-if="!isCookMode" class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
<BaseButton v-if="!isEditForm && showCookMode" minor cancel color="primary" @click="toggleCookMode()"> <BaseButton v-if="!isEditForm && !isCookMode" minor cancel color="primary" @click="toggleCookMode()">
<template #icon> <template #icon>
{{ $globals.icons.primary }} {{ $globals.icons.primary }}
</template> </template>
@ -243,16 +243,31 @@
</DropZone> </DropZone>
<v-expand-transition> <v-expand-transition>
<div v-show="!isChecked(index) && !isEditForm" class="m-0 p-0"> <div v-show="!isChecked(index) && !isEditForm" class="m-0 p-0">
<v-card-text class="markdown"> <v-card-text class="markdown">
<SafeMarkdown class="markdown" :source="step.text" /> <v-row>
<div v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0"> <v-col
<v-divider class="mb-2"></v-divider> v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0"
<RecipeIngredientHtml cols="12"
v-for="ing in step.ingredientReferences" sm="5"
:key="ing.referenceId" >
:markup="getIngredientByRefId(ing.referenceId)" <div class="ml-n4">
/> <RecipeIngredients
</div> :value="recipe.recipeIngredient.filter((ing) => {
if(!step.ingredientReferences) return false
return step.ingredientReferences.map((ref) => ref.referenceId).includes(ing.referenceId || '')
})"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
:is-cook-mode="isCookMode"
/>
</div>
</v-col>
<v-divider v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.breakpoint.smAndUp" vertical ></v-divider>
<v-col>
<SafeMarkdown class="markdown" :source="step.text" />
</v-col>
</v-row>
</v-card-text> </v-card-text>
</div> </div>
</v-expand-transition> </v-expand-transition>
@ -261,7 +276,7 @@
</div> </div>
</TransitionGroup> </TransitionGroup>
</draggable> </draggable>
<v-divider class="mt-10 d-flex d-md-none"/> <v-divider v-if="!isCookMode" class="mt-10 d-flex d-md-none"/>
</section> </section>
</template> </template>
@ -287,7 +302,7 @@ import { usePageState } from "~/composables/recipe-page/shared-state";
import { useExtractIngredientReferences } from "~/composables/recipe-page/use-extract-ingredient-references"; import { useExtractIngredientReferences } from "~/composables/recipe-page/use-extract-ingredient-references";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import { 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";
interface MergerHistory { interface MergerHistory {
target: number; target: number;
source: number; source: number;
@ -300,6 +315,7 @@ export default defineComponent({
draggable, draggable,
RecipeIngredientHtml, RecipeIngredientHtml,
DropZone, DropZone,
RecipeIngredients
}, },
props: { props: {
value: { value: {
@ -322,7 +338,7 @@ export default defineComponent({
}, },
setup(props, context) { setup(props, context) {
const { i18n, req } = useContext(); const { i18n, req, $vuetify } = useContext();
const BASE_URL = detectServerBaseUrl(req); const BASE_URL = detectServerBaseUrl(req);
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug); const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);

View file

@ -662,7 +662,8 @@
"missing-food": "Create missing food: {food}", "missing-food": "Create missing food: {food}",
"no-food": "No Food" "no-food": "No Food"
}, },
"reset-servings-count": "Reset Servings Count" "reset-servings-count": "Reset Servings Count",
"not-linked-ingredients": "Additional Ingredients"
}, },
"search": { "search": {
"advanced-search": "Advanced Search", "advanced-search": "Advanced Search",

View file

@ -268,6 +268,7 @@ export interface RecipeStep {
title?: string | null; title?: string | null;
text: string; text: string;
ingredientReferences?: IngredientReferences[]; ingredientReferences?: IngredientReferences[];
summary?: string | null;
} }
export interface RecipeAsset { export interface RecipeAsset {
name: string; name: string;