1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 03:55:22 +02:00

refactor: recipe-page (#1587)

Refactor recipe page to use break up the component and make it more usable across different pages. I've left the old route in as well in case there is some functional breaks, I plan to remove it before the official release once we've tested the new editor some more in production. For now there will just have to be some duplicate components and pages around.
This commit is contained in:
Hayden 2022-08-27 10:44:58 -08:00 committed by GitHub
parent a8da1a7594
commit caa9e03050
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 3046 additions and 902 deletions

View file

@ -20,7 +20,7 @@
</BaseDialog>
<v-spacer></v-spacer>
<div v-if="!value" class="custom-btn-group ma-1">
<div v-if="!open" class="custom-btn-group ma-1">
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="slug" show-always />
<v-tooltip v-if="!locked" bottom color="info">
<template #activator="{ on, attrs }">
@ -63,7 +63,7 @@
/>
</ClientOnly>
</div>
<div v-if="value" class="custom-btn-group mb-">
<div v-if="open" class="custom-btn-group mb-">
<v-btn
v-for="(btn, index) in editorButtons"
:key="index"
@ -97,14 +97,14 @@ export default defineComponent({
required: true,
type: String,
},
open: {
required: true,
type: Boolean,
},
name: {
required: true,
type: String,
},
value: {
type: Boolean,
default: false,
},
loggedIn: {
type: Boolean,
default: false,

View file

@ -0,0 +1,15 @@
<template>
<div v-html="markup"></div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
props: {
markup: {
type: String,
required: true,
},
},
});
</script>

View file

@ -37,7 +37,8 @@ export default defineComponent({
props: {
value: {
type: Array as () => RecipeNote[],
required: true,
required: false,
default: () => [],
},
edit: {

View file

@ -0,0 +1,319 @@
<template>
<v-container :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
<RecipePageHeader :recipe="recipe" :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.new") }}</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>
<div
v-if="recipe && wakeIsSupported"
class="d-print-none d-flex px-2"
:class="$vuetify.breakpoint.smAndDown ? 'justify-center' : 'justify-end'"
>
<v-switch v-model="wakeLock" small label="Keep Screen Awake" />
</div>
<RecipePageComments
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
:recipe="recipe"
class="px-1 my-4 d-print-none"
/>
<RecipePrintView :recipe="recipe" />
</v-container>
</template>
<script lang="ts">
import { defineComponent, useContext, useRouter, computed, ref, useMeta } from "@nuxtjs/composition-api";
import { invoke, until, useWakeLock } from "@vueuse/core";
import { onMounted, onUnmounted } from "vue-demi";
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
import RecipePageFooter from "./RecipePageParts/RecipePageFooter.vue";
import RecipePageHeader from "./RecipePageParts/RecipePageHeader.vue";
import RecipePageIngredientEditor from "./RecipePageParts/RecipePageIngredientEditor.vue";
import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredientToolsView.vue";
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 RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import { EditorMode, PageMode, usePageState } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/types/api";
import { Recipe } from "~/types/api-types/recipe";
import { useRecipeMeta } from "~/composables/recipes";
import { useRouteQuery } from "~/composables/use-router";
import { useUserApi } from "~/composables/api";
import { uuid4, deepCopy } from "~/composables/use-utils";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
const EDITOR_OPTIONS = {
mode: "code",
search: false,
mainMenuBar: false,
};
export default defineComponent({
components: {
RecipePageHeader,
RecipePrintView,
RecipePageComments,
RecipePageTitleContent,
RecipePageEditorToolbar,
RecipePageIngredientEditor,
RecipePageOrganizers,
RecipePageScale,
RecipePageIngredientToolsView,
RecipeDialogBulkAdd,
RecipeNotes,
RecipePageInstructions,
RecipePageFooter,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const router = useRouter();
const api = useUserApi();
const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } =
usePageState(props.recipe.slug);
/** =============================================================
* Recipe Snapshot on Mount
* this is used to determine if the recipe has been changed since the last save
* and prompts the user to save if they have unsaved changes.
*/
const originalRecipe = ref<Recipe | null>(null);
invoke(async () => {
await until(props.recipe).not.toBeNull();
originalRecipe.value = deepCopy(props.recipe);
});
onUnmounted(async () => {
const isSame = JSON.stringify(props.recipe) === JSON.stringify(originalRecipe.value);
if (isEditMode.value && !isSame && props.recipe?.slug !== undefined) {
const save = window.confirm(
"You have unsaved changes. Do you want to save before leaving?\n\nOkay to save, Cancel to discard changes."
);
if (save) {
await api.recipes.updateOne(props.recipe.slug, props.recipe);
}
}
});
/** =============================================================
* Set State onMounted
*/
type BooleanString = "true" | "false" | "";
const edit = useRouteQuery<BooleanString>("edit", "");
onMounted(() => {
if (edit.value === "true") {
setMode(PageMode.EDIT);
}
});
/** =============================================================
* Wake Lock
*/
const { isSupported: wakeIsSupported, isActive, request, release } = useWakeLock();
const wakeLock = computed({
get: () => isActive,
set: () => {
if (isActive.value) {
unlockScreen();
} else {
lockScreen();
}
},
});
async function lockScreen() {
if (wakeIsSupported) {
console.log("Wake Lock Requested");
await request("screen");
}
}
async function unlockScreen() {
if (wakeIsSupported || isActive) {
console.log("Wake Lock Released");
await release();
}
}
onMounted(() => lockScreen());
onUnmounted(() => unlockScreen());
/** =============================================================
* Recipe Save Delete
*/
async function saveRecipe() {
const { data } = await api.recipes.updateOne(props.recipe.slug, props.recipe);
setMode(PageMode.VIEW);
if (data?.slug) {
router.push("/recipe/" + data.slug);
}
}
async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(props.recipe.slug);
if (data?.slug) {
router.push("/");
}
}
/** =============================================================
* View Preferences
*/
const { $vuetify } = useContext();
const landscape = computed(() => {
const preferLandscape = props.recipe.settings.landscapeView;
const smallScreen = !$vuetify.breakpoint.smAndUp;
if (preferLandscape) {
return true;
} else if (smallScreen) {
return true;
}
return false;
});
/** =============================================================
* Bulk Step Editor
* TODO: Move to RecipePageInstructions component
*/
function addStep(steps: Array<string> | null = null) {
if (!props.recipe.recipeInstructions) {
return;
}
if (steps) {
const cleanedSteps = steps.map((step) => {
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
});
props.recipe.recipeInstructions.push(...cleanedSteps);
} else {
props.recipe.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] });
}
}
/** =============================================================
* Meta Tags
*/
const metaData = useRecipeMeta(ref(props.recipe));
useMeta(metaData);
return {
api,
scale: ref(1),
EDITOR_OPTIONS,
landscape,
pageMode,
editMode,
PageMode,
EditorMode,
isEditMode,
isEditForm,
isEditJSON,
isCookMode,
toggleCookMode,
wakeLock,
wakeIsSupported,
saveRecipe,
deleteRecipe,
addStep,
};
},
head: {},
});
</script>
<style lang="css">
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
}
.list-group {
min-height: 38px;
}
.list-group-item {
cursor: move;
}
.list-group-item i {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,117 @@
<template>
<div>
<v-card-title class="headline pb-3">
<v-icon class="mr-2">
{{ $globals.icons.commentTextMultipleOutline }}
</v-icon>
{{ $t("recipe.comments") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<div v-if="user.id" class="d-flex flex-column">
<div class="d-flex mt-3" style="gap: 10px">
<UserAvatar size="40" :user-id="user.id" />
<v-textarea
v-model="comment"
hide-details=""
dense
single-line
outlined
auto-grow
rows="2"
:placeholder="$t('recipe.join-the-conversation')"
>
</v-textarea>
</div>
<div class="ml-auto mt-1">
<BaseButton small :disabled="!comment" @click="submitComment">
<template #icon>{{ $globals.icons.check }}</template>
{{ $t("general.submit") }}
</BaseButton>
</div>
</div>
<div v-for="comment in comments" :key="comment.id" class="d-flex my-2" style="gap: 10px">
<UserAvatar size="40" :user-id="comment.userId" />
<v-card outlined class="flex-grow-1">
<v-card-text class="pa-3 pb-0">
<p class="">{{ comment.user.username }} {{ $d(Date.parse(comment.createdAt), "medium") }}</p>
{{ comment.text }}
</v-card-text>
<v-card-actions class="justify-end mt-0 pt-0">
<v-btn
v-if="user.id == comment.user.id || user.admin"
color="error"
text
x-small
@click="deleteComment(comment.id)"
>
{{ $t("general.delete") }}
</v-btn>
</v-card-actions>
</v-card>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, toRefs, onMounted, reactive } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { Recipe, RecipeCommentOut } from "~/types/api-types/recipe";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { NoUndefinedField } from "~/types/api";
import { usePageUser } from "~/composables/recipe-page/shared-state";
export default defineComponent({
components: {
UserAvatar,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const api = useUserApi();
const comments = ref<RecipeCommentOut[]>([]);
const { user } = usePageUser();
const state = reactive({
comment: "",
});
onMounted(async () => {
const { data } = await api.recipes.comments.byRecipe(props.recipe.slug);
if (data) {
comments.value = data;
}
});
async function submitComment() {
const { data } = await api.recipes.comments.createOne({
recipeId: props.recipe.id,
text: state.comment,
});
if (data) {
comments.value.push(data);
}
state.comment = "";
}
async function deleteComment(id: string) {
const { response } = await api.recipes.comments.deleteOne(id);
if (response?.status === 200) {
comments.value = comments.value.filter((comment) => comment.id !== id);
}
}
return { api, comments, ...toRefs(state), submitComment, deleteComment, user };
},
});
</script>

View file

@ -0,0 +1,59 @@
<template>
<div class="d-flex justify-start align-center">
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" />
<RecipeSettingsMenu
class="my-1 mx-1"
:value="recipe.settings"
:is-owner="recipe.userId == user.id"
@upload="uploadImage"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, onUnmounted } from "@nuxtjs/composition-api";
import { clearPageState, usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/types/api";
import { Recipe } from "~/types/api-types/recipe";
import { useUserApi } from "~/composables/api";
import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue";
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
export default defineComponent({
components: {
RecipeImageUploadBtn,
RecipeSettingsMenu,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { user } = usePageUser();
const api = useUserApi();
const { imageKey } = usePageState(props.recipe.slug);
onUnmounted(() => {
clearPageState(props.recipe.slug);
console.debug("reset RecipePage state during unmount");
});
async function uploadImage(fileObject: File) {
if (!props.recipe || !props.recipe.slug) {
return;
}
const newVersion = await api.recipes.updateImage(props.recipe.slug, fileObject);
if (newVersion?.data?.image) {
props.recipe.image = newVersion.data.image;
}
imageKey.value++;
}
return {
user,
uploadImage,
imageKey,
};
},
});
</script>

View file

@ -0,0 +1,113 @@
<template>
<div>
<v-card-actions class="justify-end">
<v-text-field
v-if="isEditForm"
v-model="recipe.orgURL"
class="mt-10"
:label="$t('recipe.original-url')"
></v-text-field>
<v-btn
v-else-if="recipe.orgURL && !isCookMode"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
:href="recipe.orgURL"
color="secondary darken-1"
target="_blank"
class="rounded-sm mr-4"
>
{{ $t("recipe.original-url") }}
</v-btn>
</v-card-actions>
<AdvancedOnly>
<v-card v-if="isEditForm" flat class="ma-2 mb-2">
<v-card-title> API Extras </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
Recipes extras are a key feature of the Mealie API. They allow you to create custom json key/value pairs
within a recipe to reference from 3rd part applications. You can use these keys to contain information to
trigger automation or custom messages to relay to your desired device.
<v-row v-for="(_, key) in recipe.extras" :key="key" class="mt-1">
<v-col cols="8">
<v-text-field v-model="recipe.extras[key]" dense :label="key">
<template #prepend>
<v-btn color="error" icon class="mt-n4" @click="removeApiExtra(key)">
<v-icon> {{ $globals.icons.delete }} </v-icon>
</v-btn>
</template>
</v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex">
<div style="max-width: 200px">
<v-text-field v-model="apiNewKey" label="Message Key"></v-text-field>
</div>
<BaseButton create small class="ml-5" @click="createApiExtra" />
</v-card-actions>
</v-card>
</AdvancedOnly>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/types/api";
import { Recipe } from "~/types/api-types/recipe";
export default defineComponent({
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { isEditForm, isCookMode } = usePageState(props.recipe.slug);
const apiNewKey = ref("");
function createApiExtra() {
if (!props.recipe) {
return;
}
if (!props.recipe.extras) {
props.recipe.extras = {};
}
// check for duplicate keys
if (Object.keys(props.recipe.extras).includes(apiNewKey.value)) {
return;
}
props.recipe.extras[apiNewKey.value] = "";
apiNewKey.value = "";
}
function removeApiExtra(key: string | number) {
if (!props.recipe) {
return;
}
if (!props.recipe.extras) {
return;
}
delete props.recipe.extras[key];
props.recipe.extras = { ...props.recipe.extras };
}
return {
removeApiExtra,
createApiExtra,
apiNewKey,
isEditForm,
isCookMode,
};
},
});
</script>

View file

@ -0,0 +1,112 @@
<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" :name="recipe.name" :slug="recipe.slug" />
</v-card-title>
<v-divider class="my-2"></v-divider>
<SafeMarkdown :source="recipe.description" />
<v-divider></v-divider>
<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="recipeImage(recipe.id, recipe.image, imageKey)"
class="d-print-none"
@error="hideImage = true"
>
</v-img>
</div>
<v-divider></v-divider>
<RecipeActionMenu
:slug="recipe.slug"
:locked="user.id !== recipe.userId && recipe.settings.locked"
:name="recipe.name"
:logged-in="$auth.loggedIn"
:open="isEditMode"
:recipe-id="recipe.id"
class="ml-auto mt-n8 pb-4"
@close="setMode(PageMode.VIEW)"
@json="toggleEditMode()"
@edit="setMode(PageMode.EDIT)"
@save="$emit('save')"
@delete="$emit('delete')"
@print="printRecipe"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, useContext, computed, ref } from "@nuxtjs/composition-api";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { useStaticRoutes } from "~/composables/api";
import { Recipe } from "~/types/api-types/recipe";
import { NoUndefinedField } from "~/types/api";
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
export default defineComponent({
components: {
RecipeTimeCard,
RecipeActionMenu,
RecipeRating,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
landscape: {
type: Boolean,
default: false,
},
},
setup(props) {
const { recipeImage } = useStaticRoutes();
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
function printRecipe() {
print();
}
const { $vuetify } = useContext();
const hideImage = ref(false);
const imageHeight = computed(() => {
return $vuetify.breakpoint.xs ? "200" : "400";
});
return {
setMode,
toggleEditMode,
recipeImage,
imageKey,
user,
PageMode,
pageMode,
EditorMode,
editMode,
printRecipe,
imageHeight,
hideImage,
isEditMode,
};
},
});
</script>

View file

@ -0,0 +1,148 @@
<template>
<div>
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<draggable
v-if="recipe.recipeIngredient.length > 0"
v-model="recipe.recipeIngredient"
handle=".handle"
v-bind="{
animation: 200,
group: 'description',
disabled: false,
ghostClass: 'ghost',
}"
@start="drag = true"
@end="drag = false"
>
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
<RecipeIngredientEditor
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
class="list-group-item"
:disable-amount="recipe.settings.disableAmount"
@delete="recipe.recipeIngredient.splice(index, 1)"
/>
</TransitionGroup>
</draggable>
<v-skeleton-loader v-else boilerplate elevation="2" type="list-item"> </v-skeleton-loader>
<div class="d-flex justify-end mt-2">
<v-tooltip top color="accent">
<template #activator="{ on, attrs }">
<span v-on="on">
<BaseButton
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
color="accent"
:to="`${recipe.slug}/ingredient-parser`"
v-bind="attrs"
>
<template #icon>
{{ $globals.icons.foods }}
</template>
Parse
</BaseButton>
</span>
</template>
<span>{{ parserToolTip }}</span>
</v-tooltip>
<RecipeDialogBulkAdd class="ml-1 mr-1" @bulk-data="addIngredient" />
<BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton>
</div>
</div>
</template>
<script lang="ts">
import draggable from "vuedraggable";
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/types/api";
import { Recipe } from "~/types/api-types/recipe";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import { uuid4 } from "~/composables/use-utils";
export default defineComponent({
components: {
draggable,
RecipeDialogBulkAdd,
RecipeIngredientEditor,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { user } = usePageUser();
const { imageKey } = usePageState(props.recipe.slug);
const drag = ref(false);
const hasFoodOrUnit = computed(() => {
if (!props.recipe) {
return false;
}
if (props.recipe.recipeIngredient) {
for (const ingredient of props.recipe.recipeIngredient) {
if (ingredient.food || ingredient.unit) {
return true;
}
}
}
return false;
});
const parserToolTip = computed(() => {
if (props.recipe.settings.disableAmount) {
return "Enable ingredient amounts to use this feature";
} else if (hasFoodOrUnit.value) {
return "Recipes with units or foods defined cannot be parsed.";
}
return "Parse ingredients";
});
function addIngredient(ingredients: Array<string> | null = null) {
if (ingredients?.length) {
const newIngredients = ingredients.map((x) => {
return {
referenceId: uuid4(),
title: "",
note: x,
unit: undefined,
food: undefined,
disableAmount: true,
quantity: 1,
};
});
if (newIngredients) {
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
props.recipe.recipeIngredient.push(...newIngredients);
}
} else {
props.recipe.recipeIngredient.push({
referenceId: uuid4(),
title: "",
note: "",
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
food: undefined,
disableAmount: true,
quantity: 1,
});
}
}
return {
user,
addIngredient,
parserToolTip,
hasFoodOrUnit,
imageKey,
drag,
};
},
});
</script>

View file

@ -0,0 +1,60 @@
<template>
<div>
<RecipeIngredients
:value="recipe.recipeIngredient"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
/>
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
<h2 class="mb-2 mt-4">Required Tools</h2>
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
<v-checkbox
v-model="recipe.tools[index].onHand"
hide-details
class="pt-0 my-auto py-auto"
color="secondary"
@change="toolStore.actions.updateOne(recipe.tools[index])"
>
</v-checkbox>
<v-list-item-content>
{{ tool.name }}
</v-list-item-content>
</v-list-item>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { useToolStore } from "~/composables/store";
import { NoUndefinedField } from "~/types/api";
import { Recipe } from "~/types/api-types/recipe";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
export default defineComponent({
components: {
RecipeIngredients,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
scale: {
type: Number,
required: true,
},
},
setup(props) {
const toolStore = useToolStore();
const { isEditMode } = usePageState(props.recipe.slug);
return {
toolStore,
isEditMode,
};
},
});
</script>

View file

@ -0,0 +1,700 @@
<template>
<section @keyup.ctrl.90="undoMerge">
<!-- Ingredient Link Editor -->
<v-dialog v-model="dialog" width="600">
<v-card :ripple="false">
<v-app-bar dark color="primary" class="mt-n1 mb-3">
<v-icon large left>
{{ $globals.icons.link }}
</v-icon>
<v-toolbar-title class="headline"> {{ $t("recipe.ingredient-linker") }} </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text class="pt-4">
<p>
{{ activeText }}
</p>
<v-divider class="mb-4"></v-divider>
<v-checkbox
v-for="ing in unusedIngredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="mb-n2 mt-n2"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing, recipe.settings.disableAmount)" />
</template>
</v-checkbox>
<template v-if="usedIngredients.length > 0">
<h4 class="py-3 ml-1">{{ $t("recipe.linked-to-other-step") }}</h4>
<v-checkbox
v-for="ing in usedIngredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="mb-n2 mt-n2"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing, recipe.settings.disableAmount)" />
</template>
</v-checkbox>
</template>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<BaseButton cancel @click="dialog = false"> </BaseButton>
<v-spacer></v-spacer>
<BaseButton color="info" @click="autoSetReferences">
<template #icon> {{ $globals.icons.robot }}</template>
{{ $t("recipe.auto") }}
</BaseButton>
<BaseButton save @click="setIngredientIds"> </BaseButton>
</v-card-actions>
</v-card>
</v-dialog>
<div class="d-flex justify-space-between justify-start">
<h2 class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
<BaseButton v-if="!isEditForm && showCookMode" minor cancel color="primary" @click="toggleCookMode()">
<template #icon>
{{ $globals.icons.primary }}
</template>
{{ $t("recipe.cook-mode") }}
</BaseButton>
</div>
<draggable
:disabled="!isEditForm"
:value="value"
handle=".handle"
v-bind="{
animation: 200,
group: 'description',
ghostClass: 'ghost',
}"
@input="updateIndex"
@start="drag = true"
@end="drag = false"
>
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
<div v-for="(step, index) in value" :key="step.id" class="list-group-item">
<v-app-bar
v-if="step.id && showTitleEditor[step.id]"
class="primary mx-1 mt-6"
style="cursor: pointer"
dark
dense
rounded
@click="toggleCollapseSection(index)"
>
<v-toolbar-title v-if="!isEditForm" class="headline">
<v-app-bar-title> {{ step.title }} </v-app-bar-title>
</v-toolbar-title>
<v-text-field
v-if="isEditForm"
v-model="step.title"
class="headline pa-0 mt-5"
dense
solo
flat
:placeholder="$t('recipe.section-title')"
background-color="primary"
>
</v-text-field>
</v-app-bar>
<v-hover v-slot="{ hover }">
<v-card
class="ma-1"
:class="[{ 'on-hover': hover }, isChecked(index)]"
:elevation="hover ? 12 : 2"
:ripple="false"
@click="toggleDisabled(index)"
>
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
<span class="handle">
<v-icon v-if="isEditForm" size="26" class="pb-1">{{ $globals.icons.arrowUpDown }}</v-icon>
{{ $t("recipe.step-index", { step: index + 1 }) }}
</span>
<template v-if="isEditForm">
<div class="ml-auto">
<BaseButtonGroup
:large="false"
:buttons="[
{
icon: $globals.icons.delete,
text: $tc('general.delete'),
event: 'delete',
},
{
icon: $globals.icons.dotsVertical,
text: '',
event: 'open',
children: [
{
text: 'Toggle Section',
event: 'toggle-section',
},
{
text: 'Link Ingredients',
event: 'link-ingredients',
},
{
text: 'Merge Above',
event: 'merge-above',
},
{
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
text: previewStates[index] ? 'Edit Markdown' : 'Preview Markdown',
event: 'preview-step',
},
],
},
]"
@merge-above="mergeAbove(index - 1, index)"
@toggle-section="toggleShowTitle(step.id)"
@link-ingredients="openDialog(index, step.text, step.ingredientReferences)"
@preview-step="togglePreviewState(index)"
@delete="value.splice(index, 1)"
/>
</div>
</template>
<v-fade-transition>
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
{{ $globals.icons.checkboxMarkedCircle }}
</v-icon>
</v-fade-transition>
</v-card-title>
<!-- Content -->
<v-card-text
v-if="isEditForm"
:class="{ blur: imageUploadMode }"
@drop.stop.prevent="handleImageDrop(index, $event)"
>
<MarkdownEditor
v-model="value[index]['text']"
class="mb-2"
:preview.sync="previewStates[index]"
:display-preview="false"
:textarea="{
hint: 'Attach images by dragging & dropping them into the editor',
persistentHint: true,
}"
/>
<RecipeIngredientHtml
v-for="ing in step.ingredientReferences"
:key="ing.referenceId"
:markup="getIngredientByRefId(ing.referenceId)"
/>
</v-card-text>
<v-expand-transition>
<div v-show="!isChecked(index) && !isEditForm" class="m-0 p-0">
<v-card-text class="markdown">
<SafeMarkdown class="markdown" :source="step.text" />
<div v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0">
<v-divider class="mb-2"></v-divider>
<RecipeIngredientHtml
v-for="ing in step.ingredientReferences"
:key="ing.referenceId"
:markup="getIngredientByRefId(ing.referenceId)"
/>
</div>
</v-card-text>
</div>
</v-expand-transition>
</v-card>
</v-hover>
</div>
</TransitionGroup>
</draggable>
</section>
</template>
<script lang="ts">
import draggable from "vuedraggable";
import {
ref,
toRefs,
reactive,
defineComponent,
watch,
onMounted,
useContext,
computed,
} from "@nuxtjs/composition-api";
import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue";
import { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/types/api-types/recipe";
import { parseIngredientText } from "~/composables/recipes";
import { uuid4, detectServerBaseUrl } from "~/composables/use-utils";
import { useUserApi, useStaticRoutes } from "~/composables/api";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/types/api";
interface MergerHistory {
target: number;
source: number;
targetText: string;
sourceText: string;
}
export default defineComponent({
components: {
draggable,
RecipeIngredientHtml,
},
props: {
value: {
type: Array as () => RecipeStep[],
required: false,
default: () => [],
},
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
assets: {
type: Array as () => RecipeAsset[],
required: true,
},
scale: {
type: Number,
default: 1,
},
},
setup(props, context) {
const { i18n, req } = useContext();
const BASE_URL = detectServerBaseUrl(req);
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
const state = reactive({
dialog: false,
disabledSteps: [] as number[],
unusedIngredients: [] as RecipeIngredient[],
usedIngredients: [] as RecipeIngredient[],
});
const showTitleEditor = ref<{ [key: string]: boolean }>({});
const actionEvents = [
{
text: i18n.t("recipe.toggle-section") as string,
event: "toggle-section",
},
{
text: i18n.t("recipe.link-ingredients") as string,
event: "link-ingredients",
},
{
text: i18n.t("recipe.merge-above") as string,
event: "merge-above",
},
];
// ===============================================================
// UI State Helpers
function validateTitle(title: string | undefined) {
return !(title === null || title === "" || title === undefined);
}
watch(props.value, (v) => {
state.disabledSteps = [];
v.forEach((element: RecipeStep) => {
if (element.id !== undefined) {
showTitleEditor.value[element.id] = validateTitle(element.title);
}
});
});
const showCookMode = ref(false);
// Eliminate state with an eager call to watcher?
onMounted(() => {
props.value.forEach((element: RecipeStep) => {
if (element.id !== undefined) {
showTitleEditor.value[element.id] = validateTitle(element.title);
}
// showCookMode.value = false;
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
showCookMode.value = true;
}
showTitleEditor.value = { ...showTitleEditor.value };
});
});
function toggleDisabled(stepIndex: number) {
if (isEditForm.value) {
return;
}
if (state.disabledSteps.includes(stepIndex)) {
const index = state.disabledSteps.indexOf(stepIndex);
if (index !== -1) {
state.disabledSteps.splice(index, 1);
}
} else {
state.disabledSteps.push(stepIndex);
}
}
function isChecked(stepIndex: number) {
if (state.disabledSteps.includes(stepIndex) && !isEditForm.value) {
return "disabled-card";
}
}
function toggleShowTitle(id?: string) {
if (!id) {
return;
}
showTitleEditor.value[id] = !showTitleEditor.value[id];
const temp = { ...showTitleEditor.value };
showTitleEditor.value = temp;
}
function updateIndex(data: RecipeStep) {
context.emit("input", data);
}
// ===============================================================
// Ingredient Linker
const activeRefs = ref<string[]>([]);
const activeIndex = ref(0);
const activeText = ref("");
function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
if (!refs) {
props.value[idx].ingredientReferences = [];
refs = props.value[idx].ingredientReferences as IngredientReferences[];
}
setUsedIngredients();
activeText.value = text;
activeIndex.value = idx;
state.dialog = true;
activeRefs.value = refs.map((ref) => ref.referenceId ?? "");
}
function setIngredientIds() {
const instruction = props.value[activeIndex.value];
instruction.ingredientReferences = activeRefs.value.map((ref) => {
return {
referenceId: ref,
};
});
// Update the visibility of the cook mode button
showCookMode.value = false;
props.value.forEach((element) => {
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
showCookMode.value = true;
}
});
state.dialog = false;
}
function setUsedIngredients() {
const usedRefs: { [key: string]: boolean } = {};
props.value.forEach((element) => {
element.ingredientReferences?.forEach((ref) => {
if (ref.referenceId !== undefined) {
usedRefs[ref.referenceId] = true;
}
});
});
state.usedIngredients = props.recipe.recipeIngredient.filter((ing) => {
return ing.referenceId !== undefined && ing.referenceId in usedRefs;
});
state.unusedIngredients = props.recipe.recipeIngredient.filter((ing) => {
return !(ing.referenceId !== undefined && ing.referenceId in usedRefs);
});
}
function autoSetReferences() {
// Ignore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
// at the food variable and seeing if the food is in the instructions, but I still need to support those who don't want to provide the value
// and only use the "notes" feature.
const blackListedText = [
"and",
"or",
"the",
"a",
"an",
"of",
"in",
"on",
"to",
"for",
"by",
"with",
"without",
"",
" ",
];
const blackListedRegexMatch = /\d/gm; // Match Any Number
// Check if any of the words in the active text match the ingredient text
const instructionsByWord = activeText.value.toLowerCase().split(" ");
instructionsByWord.forEach((word) => {
if (blackListedText.includes(word) || word.match(blackListedRegexMatch)) {
return;
}
props.recipe.recipeIngredient.forEach((ingredient) => {
const searchText = parseIngredientText(ingredient, props.recipe.settings.disableAmount);
if (ingredient.referenceId === undefined) {
return;
}
if (searchText.toLowerCase().includes(" " + word) && !activeRefs.value.includes(ingredient.referenceId)) {
console.info("Word Matched", `'${word}'`, ingredient.note);
activeRefs.value.push(ingredient.referenceId);
}
});
});
}
const ingredientLookup = computed(() => {
const results: { [key: string]: RecipeIngredient } = {};
return props.recipe.recipeIngredient.reduce((prev, ing) => {
if (ing.referenceId === undefined) {
return prev;
}
prev[ing.referenceId] = ing;
return prev;
}, results);
});
function getIngredientByRefId(refId: string | undefined) {
if (refId === undefined) {
return "";
}
const ing = ingredientLookup.value[refId] ?? "";
if (ing === "") {
return "";
}
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
}
// ===============================================================
// Instruction Merger
const mergeHistory = ref<MergerHistory[]>([]);
function mergeAbove(target: number, source: number) {
if (target < 0) {
return;
}
mergeHistory.value.push({
target,
source,
targetText: props.value[target].text,
sourceText: props.value[source].text,
});
props.value[target].text += " " + props.value[source].text;
props.value.splice(source, 1);
}
function undoMerge(event: KeyboardEvent) {
if (event.ctrlKey && event.code === "KeyZ") {
if (!(mergeHistory.value?.length > 0)) {
return;
}
const lastMerge = mergeHistory.value.pop();
if (!lastMerge) {
return;
}
props.value[lastMerge.target].text = lastMerge.targetText;
props.value.splice(lastMerge.source, 0, {
id: uuid4(),
title: "",
text: lastMerge.sourceText,
ingredientReferences: [],
});
}
}
const previewStates = ref<boolean[]>([]);
function togglePreviewState(index: number) {
const temp = [...previewStates.value];
temp[index] = !temp[index];
previewStates.value = temp;
}
function toggleCollapseSection(index: number) {
const sectionSteps: number[] = [];
for (let i = index; i < props.value.length; i++) {
if (!(i === index) && validateTitle(props.value[i].title)) {
break;
} else {
sectionSteps.push(i);
}
}
const allCollapsed = sectionSteps.every((idx) => state.disabledSteps.includes(idx));
if (allCollapsed) {
state.disabledSteps = state.disabledSteps.filter((idx) => !sectionSteps.includes(idx));
} else {
state.disabledSteps = [...state.disabledSteps, ...sectionSteps];
}
}
const drag = ref(false);
// ===============================================================
// Image Uploader
const api = useUserApi();
const { recipeAssetPath } = useStaticRoutes();
const imageUploadMode = ref(false);
function toggleDragMode() {
console.log("Toggling Drag Mode");
imageUploadMode.value = !imageUploadMode.value;
}
onMounted(() => {
if (props.assets === undefined) {
context.emit("update:assets", []);
}
});
async function handleImageDrop(index: number, e: DragEvent) {
if (!e.dataTransfer) {
return;
}
// Check if the file is an image
const file = e.dataTransfer.files[0];
if (!file || !file.type.startsWith("image/")) {
return;
}
const { data } = await api.recipes.createAsset(props.recipe.slug, {
name: file.name,
icon: "mdi-file-image",
file,
extension: file.name.split(".").pop() || "",
});
if (!data) {
return; // TODO: Handle error
}
context.emit("update:assets", [...props.assets, data]);
const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string);
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
props.value[index].text += text;
}
return {
// Image Uploader
toggleDragMode,
handleImageDrop,
imageUploadMode,
// Rest
drag,
togglePreviewState,
toggleCollapseSection,
previewStates,
...toRefs(state),
actionEvents,
activeRefs,
activeText,
getIngredientByRefId,
showTitleEditor,
mergeAbove,
openDialog,
setIngredientIds,
undoMerge,
toggleDisabled,
isChecked,
toggleShowTitle,
updateIndex,
autoSetReferences,
parseIngredientText,
toggleCookMode,
showCookMode,
isCookMode,
isEditForm,
};
},
});
</script>
<style lang="css" scoped>
.v-card--link:before {
background: none;
}
/** Select all li under .markdown class */
.markdown >>> ul > li {
display: list-item;
list-style-type: disc !important;
}
/** Select all li under .markdown class */
.markdown >>> ol > li {
display: list-item;
}
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
}
.list-group {
min-height: 38px;
}
.list-group-item {
cursor: move;
}
.list-group-item i {
cursor: pointer;
}
.blur {
filter: blur(2px);
}
.upload-overlay {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
</style>

View file

@ -0,0 +1,93 @@
<template>
<div>
<!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || isEditForm" class="mt-2">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2" />
<v-card-text>
<RecipeOrganizerSelector
v-if="isEditForm"
v-model="recipe.recipeCategory"
:return-object="true"
:show-add="true"
selector-type="categories"
/>
<RecipeChips v-else :items="recipe.recipeCategory" />
</v-card-text>
</v-card>
<!-- Recipe Tags -->
<v-card v-if="recipe.tags.length > 0 || isEditForm" class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2" />
<v-card-text>
<RecipeOrganizerSelector
v-if="isEditForm"
v-model="recipe.tags"
:return-object="true"
:show-add="true"
selector-type="tags"
/>
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" />
</v-card-text>
</v-card>
<!-- Recipe Tools Edit -->
<v-card v-if="isEditForm" class="mt-2">
<v-card-title class="py-2"> Required Tools </v-card-title>
<v-divider class="mx-2" />
<v-card-text class="pt-0">
<RecipeOrganizerSelector v-model="recipe.tools" selector-type="tools" />
</v-card-text>
</v-card>
<RecipeNutrition v-if="recipe.settings.showNutrition" v-model="recipe.nutrition" class="mt-10" :edit="isEditForm" />
<client-only>
<RecipeAssets
v-if="recipe.settings.showAssets"
v-model="recipe.assets"
:edit="isEditForm"
:slug="recipe.slug"
:recipe-id="recipe.id"
/>
</client-only>
</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/types/api";
import { Recipe } from "~/types/api-types/recipe";
import RecipeOrganizerSelector from "@/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
import RecipeChips from "@/components/Domain/Recipe/RecipeChips.vue";
import RecipeAssets from "@/components/Domain/Recipe/RecipeAssets.vue";
export default defineComponent({
components: {
RecipeOrganizerSelector,
RecipeNutrition,
RecipeChips,
RecipeAssets,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { user } = usePageUser();
const { isEditForm } = usePageState(props.recipe.slug);
return {
isEditForm,
user,
};
},
});
</script>

View file

@ -0,0 +1,101 @@
<template>
<div class="d-flex justify-space-between align-center pt-2 pb-3">
<v-tooltip v-if="!isEditMode && recipe.recipeYield" small top color="secondary darken-1">
<template #activator="{ on, attrs }">
<RecipeScaleEditButton
v-model.number="scaleValue"
v-bind="attrs"
:recipe-yield="recipe.recipeYield"
:basic-yield="basicYield"
:scaled-yield="scaledYield"
: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"
:value="recipe.rating"
:name="recipe.name"
:slug="recipe.slug"
/>
</div>
</template>
<script lang="ts">
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 "~/types/api";
import { Recipe } from "~/types/api-types/recipe";
import { usePageState } from "~/composables/recipe-page/shared-state";
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,
},
},
setup(props, { emit }) {
const { isEditMode } = usePageState(props.recipe.slug);
const scaleValue = computed<number>({
get() {
return props.scale;
},
set(val) {
emit("update:scale", val);
},
});
const scaledYield = computed(() => {
const regMatchNum = /\d+/;
const yieldString = props.recipe.recipeYield;
const num = yieldString?.match(regMatchNum);
if (num && num?.length > 0) {
const yieldAsInt = parseInt(num[0]);
return yieldString?.replace(num[0], String(yieldAsInt * scaleValue.value));
}
return props.recipe.recipeYield;
});
const basicYield = computed(() => {
const regMatchNum = /\d+/;
const yieldString = props.recipe.recipeYield;
const num = yieldString?.match(regMatchNum);
if (num && num?.length > 0) {
const yieldAsInt = parseInt(num[0]);
return yieldString?.replace(num[0], String(yieldAsInt));
}
return props.recipe.recipeYield;
});
return {
scaleValue,
basicYield,
scaledYield,
isEditMode,
};
},
});
</script>

View file

@ -0,0 +1,79 @@
<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 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"
:value="recipe.rating"
:name="recipe.name"
: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 { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { validators } from "~/composables/use-validators";
import { NoUndefinedField } from "~/types/api";
import { Recipe } from "~/types/api-types/recipe";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
export default defineComponent({
components: {
RecipeRating,
RecipeTimeCard,
},
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);
return {
user,
imageKey,
validators,
isEditMode,
};
},
});
</script>

View file

@ -0,0 +1,3 @@
import RecipePage from "./RecipePage.vue";
export default RecipePage;

View file

@ -5,7 +5,7 @@
:buttons="[
{
icon: previewState ? $globals.icons.edit : $globals.icons.eye,
text: previewState ? $t('general.edit') : $t('markdown-editor.preview-markdown-button-label'),
text: previewState ? $tc('general.edit') : $tc('markdown-editor.preview-markdown-button-label'),
event: 'toggle',
},
]"
@ -49,7 +49,7 @@ export default defineComponent({
default: true,
},
textarea: {
type: Object,
type: Object as () => unknown,
default: () => ({}),
},
},

View file

@ -1,6 +1,10 @@
import { useContext } from "@nuxtjs/composition-api";
import { detectServerBaseUrl } from "../use-utils";
function UnknownToString(ukn: string | unknown) {
return typeof ukn === "string" ? ukn : "";
}
export const useStaticRoutes = () => {
const { $config, req } = useContext();
const serverBase = detectServerBaseUrl(req);
@ -10,16 +14,20 @@ export const useStaticRoutes = () => {
const fullBase = serverBase + prefix;
// Methods to Generate reference urls for assets/images *
function recipeImage(recipeId: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/original.webp?rnd=${key}&version=${version}`;
function recipeImage(recipeId: string, version: string | unknown = "", key: string | number = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/original.webp?rnd=${key}&version=${UnknownToString(version)}`;
}
function recipeSmallImage(recipeId: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/min-original.webp?rnd=${key}&version=${version}`;
function recipeSmallImage(recipeId: string, version: string | unknown = "", key: string | number = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/min-original.webp?rnd=${key}&version=${UnknownToString(
version
)}`;
}
function recipeTinyImage(recipeId: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/tiny-original.webp?rnd=${key}&version=${version}`;
function recipeTinyImage(recipeId: string, version: string | unknown = "", key: string | number = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/tiny-original.webp?rnd=${key}&version=${UnknownToString(
version
)}`;
}
function recipeAssetPath(recipeId: string, assetName: string) {

View file

@ -0,0 +1,155 @@
import { computed, ComputedRef, ref, Ref, useContext } from "@nuxtjs/composition-api";
import { UserOut } from "~/types/api-types/user";
export enum PageMode {
EDIT = "EDIT",
VIEW = "VIEW",
COOK = "COOK",
}
export enum EditorMode {
JSON = "JSON",
FORM = "FORM",
}
/**
* PageState encapsulates the state of the recipe page the can be shared across components.
* It allows and facilitates the complex state management of the recipe page where many components
* need to share and communicate with each other and guarantee consistency.
*
* **Page Modes**
*
* are ComputedRefs so we can use a readonly reactive copy of the state of the page.
*/
interface PageState {
slug: Ref<string>;
imageKey: Ref<number>;
pageMode: ComputedRef<PageMode>;
editMode: ComputedRef<EditorMode>;
/**
* true is the page is in edit mode and the edit mode is in form mode.
*/
isEditForm: ComputedRef<boolean>;
/**
* true is the page is in edit mode and the edit mode is in json mode.
*/
isEditJSON: ComputedRef<boolean>;
/**
* true is the page is in view mode.
*/
isEditMode: ComputedRef<boolean>;
/**
* true is the page is in cook mode.
*/
isCookMode: ComputedRef<boolean>;
setMode: (v: PageMode) => void;
setEditMode: (v: EditorMode) => void;
toggleEditMode: () => void;
toggleCookMode: () => void;
}
const memo: Record<string, PageState> = {};
function pageStateConstructor(slug: string): PageState {
const slugRef = ref(slug);
const pageModeRef = ref(PageMode.VIEW);
const editModeRef = ref(EditorMode.FORM);
const toggleEditMode = () => {
if (editModeRef.value === EditorMode.FORM) {
editModeRef.value = EditorMode.JSON;
return;
}
editModeRef.value = EditorMode.FORM;
};
const toggleCookMode = () => {
if (pageModeRef.value === PageMode.COOK) {
pageModeRef.value = PageMode.VIEW;
return;
}
pageModeRef.value = PageMode.COOK;
};
const setEditMode = (v: EditorMode) => {
editModeRef.value = v;
};
const setMode = (toMode: PageMode) => {
const fromMode = pageModeRef.value;
if (fromMode === PageMode.EDIT && toMode === PageMode.VIEW) {
setEditMode(EditorMode.FORM);
}
pageModeRef.value = toMode;
};
return {
slug: slugRef,
pageMode: computed(() => pageModeRef.value),
editMode: computed(() => editModeRef.value),
imageKey: ref(1),
toggleEditMode,
setMode,
setEditMode,
toggleCookMode,
isEditForm: computed(() => {
return pageModeRef.value === PageMode.EDIT && editModeRef.value === EditorMode.FORM;
}),
isEditJSON: computed(() => {
return pageModeRef.value === PageMode.EDIT && editModeRef.value === EditorMode.JSON;
}),
isEditMode: computed(() => {
return pageModeRef.value === PageMode.EDIT;
}),
isCookMode: computed(() => {
return pageModeRef.value === PageMode.COOK;
}),
};
}
/**
* usePageState provides a common way to interact with shared state across the
* RecipePage component.
*/
export function usePageState(slug: string): PageState {
if (!memo[slug]) {
memo[slug] = pageStateConstructor(slug);
}
return memo[slug];
}
export function clearPageState(slug: string) {
delete memo[slug];
}
/**
* usePageUser provides a wrapper around $auth that provides a type-safe way to
* access the UserOut type from the context. If no user is logged in then an empty
* object with all properties set to their zero value is returned.
*/
export function usePageUser(): { user: UserOut } {
const { $auth } = useContext();
if (!$auth.user) {
return {
user: {
id: "",
group: "",
groupId: "",
cacheKey: "",
email: "",
},
};
}
// @ts-expect-error - We know that the API always returns a UserOut, but I'm unsure how to type the $auth to know what type user is
return { user: $auth.user as UserOut };
}

View file

@ -1,905 +1,32 @@
<template>
<v-container
:class="{
'pa-0': $vuetify.breakpoint.smAndDown,
}"
>
<v-card v-if="skeleton" :color="`white ${false ? 'darken-2' : 'lighten-4'}`" class="pa-3">
<v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader>
</v-card>
<v-card v-else-if="recipe" :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
<!-- Recipe Header -->
<div class="d-flex justify-end flex-wrap align-stretch">
<v-card v-if="!enableLandscape" 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" :name="recipe.name" :slug="recipe.slug" />
</v-card-title>
<v-divider class="my-2"></v-divider>
<SafeMarkdown :source="recipe.description" />
<v-divider></v-divider>
<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>
<RecipePage v-if="recipe" :recipe="recipe" />
</div>
</v-card-text>
</v-card>
<v-img
:key="imageKey"
:max-width="enableLandscape ? null : '50%'"
min-height="50"
:height="hideImage ? undefined : imageHeight"
:src="recipeImage(recipe.id, recipe.image, imageKey)"
class="d-print-none"
@error="hideImage = true"
>
</v-img>
</div>
<v-divider></v-divider>
<RecipeActionMenu
v-model="form"
:slug="recipe.slug"
:locked="$auth.user.id !== recipe.userId && recipe.settings.locked"
:name="recipe.name"
:logged-in="$auth.loggedIn"
:open="form"
:recipe-id="recipe.id"
class="ml-auto mt-n8 pb-4"
@close="closeEditor"
@json="toggleJson"
@edit="toggleEdit"
@save="updateRecipe(recipe.slug, recipe)"
@delete="deleteRecipe(recipe.slug)"
@print="printRecipe"
/>
<!-- Editors -->
<LazyRecipeJsonEditor v-if="jsonEditor" v-model="recipe" class="mt-10" :options="jsonEditorOptions" />
<div v-else>
<v-card-text
:class="{
'px-2': $vuetify.breakpoint.smAndDown,
}"
>
<div v-if="form" class="d-flex justify-start align-center">
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" />
<RecipeSettingsMenu
class="my-1 mx-1"
:value="recipe.settings"
:is-owner="recipe.userId == $auth.user.id"
@upload="uploadImage"
/>
</div>
<!-- Recipe Title Section -->
<template v-if="!form && enableLandscape">
<v-card-title class="px-0 py-2 ma-0 headline">
{{ recipe.name }}
</v-card-title>
<SafeMarkdown :source="recipe.description" />
<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="enableLandscape && $vuetify.breakpoint.smAndDown"
:key="recipe.slug"
:value="recipe.rating"
:name="recipe.name"
:slug="recipe.slug"
/>
</div>
<v-divider></v-divider>
</template>
<template v-else-if="form">
<v-text-field
v-model="recipe.name"
class="my-3"
:label="$t('recipe.recipe-name')"
:rules="[validators.required]"
>
</v-text-field>
<div class="d-flex flex-wrap">
<v-text-field v-model="recipe.totalTime" class="mx-2" :label="$t('recipe.total-time')"></v-text-field>
<v-text-field v-model="recipe.prepTime" class="mx-2" :label="$t('recipe.prep-time')"></v-text-field>
<v-text-field v-model="recipe.performTime" class="mx-2" :label="$t('recipe.perform-time')"></v-text-field>
</div>
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')">
</v-textarea>
<v-text-field v-model="recipe.recipeYield" dense :label="$t('recipe.servings')"> </v-text-field>
</template>
<!-- Advanced Editor -->
<div v-if="form">
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<draggable
v-if="recipe.recipeIngredient.length > 0"
v-model="recipe.recipeIngredient"
handle=".handle"
v-bind="{
animation: 200,
group: 'description',
disabled: false,
ghostClass: 'ghost',
}"
@start="drag = true"
@end="drag = false"
>
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
<RecipeIngredientEditor
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
class="list-group-item"
:disable-amount="recipe.settings.disableAmount"
@delete="recipe.recipeIngredient.splice(index, 1)"
/>
</TransitionGroup>
</draggable>
<v-skeleton-loader v-else boilerplate elevation="2" type="list-item"> </v-skeleton-loader>
<div class="d-flex justify-end mt-2">
<v-tooltip top color="accent">
<template #activator="{ on, attrs }">
<span v-on="on">
<BaseButton
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
color="accent"
:to="`${recipe.slug}/ingredient-parser`"
v-bind="attrs"
>
<template #icon>
{{ $globals.icons.foods }}
</template>
Parse
</BaseButton>
</span>
</template>
<span>{{ paserToolTip }}</span>
</v-tooltip>
<RecipeDialogBulkAdd class="ml-1 mr-1" @bulk-data="addIngredient" />
<BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton>
</div>
</div>
<div class="d-flex justify-space-between align-center pt-2 pb-3">
<v-tooltip v-if="!form && recipe.recipeYield" small top color="secondary darken-1">
<template #activator="{ on, attrs }">
<RecipeScaleEditButton
v-model.number="scale"
v-bind="attrs"
:recipe-yield="recipe.recipeYield"
:basic-yield="basicYield"
:scaled-yield="scaledYield"
:edit-scale="!recipe.settings.disableAmount && !form"
v-on="on"
/>
</template>
<span> {{ $t("recipe.edit-scale") }} </span>
</v-tooltip>
<v-spacer></v-spacer>
<RecipeRating
v-if="enableLandscape && $vuetify.breakpoint.smAndUp"
:key="recipe.slug"
:value="recipe.rating"
:name="recipe.name"
:slug="recipe.slug"
/>
</div>
<v-row>
<v-col v-if="!cookModeToggle || form" cols="12" sm="12" md="4" lg="4">
<RecipeIngredients
v-if="!form"
:value="recipe.recipeIngredient"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
/>
<!-- Recipe Tools Display -->
<div v-if="!form && recipe.tools && recipe.tools.length > 0">
<h2 class="mb-2 mt-4">Required Tools</h2>
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
<v-checkbox
v-model="recipe.tools[index].onHand"
hide-details
class="pt-0 my-auto py-auto"
color="secondary"
@change="toolStore.actions.updateOne(recipe.tools[index])"
>
</v-checkbox>
<v-list-item-content>
{{ tool.name }}
</v-list-item-content>
</v-list-item>
</div>
<div v-if="$vuetify.breakpoint.mdAndUp" class="mt-5">
<!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.recipeCategory"
:return-object="true"
:show-add="true"
selector-type="categories"
/>
<RecipeChips v-else :items="recipe.recipeCategory" />
</v-card-text>
</v-card>
<!-- Recipe Tags -->
<v-card v-if="recipe.tags.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.tags"
:return-object="true"
:show-add="true"
selector-type="tags"
/>
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" />
</v-card-text>
</v-card>
<!-- Recipe Tools Edit -->
<v-card v-if="form" class="mt-2">
<v-card-title class="py-2"> Required Tools </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text class="pt-0">
<RecipeOrganizerSelector v-model="recipe.tools" selector-type="tools" />
</v-card-text>
</v-card>
<RecipeNutrition
v-if="recipe.settings.showNutrition"
v-model="recipe.nutrition"
class="mt-10"
:edit="form"
/>
<client-only>
<RecipeAssets
v-if="recipe.settings.showAssets"
v-model="recipe.assets"
:edit="form"
:slug="recipe.slug"
:recipe-id="recipe.id"
/>
</client-only>
</div>
</v-col>
<v-divider
v-if="$vuetify.breakpoint.mdAndUp && !cookModeToggle"
class="my-divider"
:vertical="true"
></v-divider>
<v-col cols="12" sm="12" :md="8 + (cookModeToggle ? 1 : 0) * 4" :lg="8 + (cookModeToggle ? 1 : 0) * 4">
<RecipeInstructions
v-model="recipe.recipeInstructions"
:assets.sync="recipe.assets"
:ingredients="recipe.recipeIngredient"
:disable-amount="recipe.settings.disableAmount"
:edit="form"
:recipe-id="recipe.id"
:recipe-slug="recipe.slug"
:cook-mode="cookModeToggle"
:scale="scale"
@cookModeToggle="cookModeToggle = !cookModeToggle"
/>
<div v-if="form" class="d-flex">
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
</div>
<!-- TODO: Somehow fix duplicate code for mobile/desktop -->
<div v-if="!$vuetify.breakpoint.mdAndUp" class="mt-5">
<!-- Recipe Tools Edit -->
<v-card v-if="form">
<v-card-title class="py-2"> Required Tools</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text class="pt-0">
<RecipeTools v-model="recipe.tools" :edit="form" />
</v-card-text>
</v-card>
<!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.recipeCategory"
:return-object="true"
:show-add="true"
selector-type="categories"
/>
<RecipeChips v-else :items="recipe.recipeCategory" />
</v-card-text>
</v-card>
<!-- Recipe Tags -->
<v-card v-if="recipe.tags.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.tags"
:return-object="true"
:show-add="true"
selector-type="tags"
/>
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" />
</v-card-text>
</v-card>
<RecipeNutrition
v-if="recipe.settings.showNutrition && !cookModeToggle"
v-model="recipe.nutrition"
class="mt-10"
:edit="form"
/>
<client-only>
<RecipeAssets
v-if="recipe.settings.showAssets && !cookModeToggle"
v-model="recipe.assets"
:edit="form"
:slug="recipe.slug"
:recipe-id="recipe.id"
/>
</client-only>
</div>
<RecipeNotes v-if="!cookModeToggle" v-model="recipe.notes" :edit="form" />
</v-col>
</v-row>
<v-card-actions class="justify-end">
<v-text-field
v-if="form"
v-model="recipe.orgURL"
class="mt-10"
:label="$t('recipe.original-url')"
></v-text-field>
<v-btn
v-else-if="recipe.orgURL && !cookModeToggle"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
:href="recipe.orgURL"
color="secondary darken-1"
target="_blank"
class="rounded-sm mr-4"
>
{{ $t("recipe.original-url") }}
</v-btn>
</v-card-actions>
</v-card-text>
</div>
<v-card v-if="form && $auth.user.advanced" flat class="ma-2 mb-2">
<v-card-title> API Extras </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
Recipes extras are a key feature of the Mealie API. They allow you to create custom json key/value pairs
within a recipe to reference from 3rd part applications. You can use these keys to contain information to
trigger automation or custom messages to relay to your desired device.
<v-row v-for="(value, key) in recipe.extras" :key="key" class="mt-1">
<v-col cols="8">
<v-text-field v-model="recipe.extras[key]" dense :label="key">
<template #prepend>
<v-btn color="error" icon class="mt-n4" @click="removeApiExtra(key)">
<v-icon> {{ $globals.icons.delete }} </v-icon>
</v-btn>
</template>
</v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex">
<div style="max-width: 200px">
<v-text-field v-model="apiNewKey" label="Message Key"></v-text-field>
</div>
<BaseButton create small class="ml-5" @click="createApiExtra" />
</v-card-actions>
</v-card>
</v-card>
<div
v-if="recipe && wakeIsSupported"
class="d-print-none d-flex px-2"
:class="$vuetify.breakpoint.smAndDown ? 'justify-center' : 'justify-end'"
>
<v-switch v-model="wakeLock" small label="Keep Screen Awake" />
</div>
<RecipeComments
v-if="recipe && !recipe.settings.disableComments && !form && !cookModeToggle"
v-model="recipe.comments"
:slug="recipe.slug"
:recipe-id="recipe.id"
class="px-1 my-4 d-print-none"
/>
<RecipePrintView v-if="recipe" :recipe="recipe" />
</v-container>
</template>
<script lang="ts">
import {
computed,
defineComponent,
reactive,
ref,
toRefs,
useContext,
useMeta,
useRoute,
useRouter,
onMounted,
} from "@nuxtjs/composition-api";
import draggable from "vuedraggable";
import { invoke, until, useWakeLock } from "@vueuse/core";
import { onUnmounted } from "vue-demi";
import RecipeOrganizerSelector from "@/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import RecipeDialogBulkAdd from "@/components/Domain/Recipe//RecipeDialogBulkAdd.vue";
import { useUserApi, useStaticRoutes } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { useRecipe, useRecipeMeta } from "~/composables/recipes";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import RecipeChips from "~/components/Domain/Recipe/RecipeChips.vue";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
import RecipeInstructions from "~/components/Domain/Recipe/RecipeInstructions.vue";
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue";
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import RecipeTools from "~/components/Domain/Recipe/RecipeTools.vue";
import RecipeComments from "~/components/Domain/Recipe/RecipeComments.vue";
import { Recipe } from "~/types/api-types/recipe";
import { uuid4, deepCopy } from "~/composables/use-utils";
import { useRouteQuery } from "~/composables/use-router";
import { useToolStore } from "~/composables/store";
import { defineComponent, useRoute } from "@nuxtjs/composition-api";
import RecipePage from "~/components/Domain/Recipe/RecipePage/RecipePage.vue";
import { useUserApi } from "~/composables/api";
import { useRecipe } from "~/composables/recipes";
export default defineComponent({
components: {
draggable,
RecipeActionMenu,
RecipeAssets: () => {
if (process.client) {
return import(/* webpackChunkName: "RecipeAssets" */ "~/components/Domain/Recipe/RecipeAssets.vue");
}
},
RecipeOrganizerSelector,
RecipeChips,
RecipeComments,
RecipeDialogBulkAdd,
RecipeImageUploadBtn,
RecipeIngredientEditor,
RecipeIngredients,
RecipeInstructions,
RecipeNotes,
RecipeNutrition,
RecipePrintView,
RecipeRating,
RecipeSettingsMenu,
RecipeTimeCard,
RecipeTools,
RecipeScaleEditButton,
},
async beforeRouteLeave(_to, _from, next) {
const isSame = JSON.stringify(this.recipe) === JSON.stringify(this.originalRecipe);
if (this.form && !isSame && this.recipe?.slug !== undefined) {
if (
window.confirm(
"You have unsaved changes. Do you want to save before leaving?\n\nOkay to save, Cancel to discard changes."
)
) {
await this.api.recipes.updateOne(this.recipe.slug, this.recipe);
}
}
next();
},
components: { RecipePage },
setup() {
const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug;
const api = useUserApi();
// ===============================================================
// Screen Lock
const { isSupported: wakeIsSupported, isActive, request, release } = useWakeLock();
const wakeLock = computed({
get: () => isActive,
set: () => {
if (isActive.value) {
unlockScreen();
} else {
lockScreen();
}
},
});
async function lockScreen() {
if (wakeIsSupported) {
console.log("Wake Lock Requested");
await request("screen");
}
}
async function unlockScreen() {
if (wakeIsSupported || isActive) {
console.log("Wake Lock Released");
await release();
}
}
// ===============================================================
// Edit on Navigate
const edit = useRouteQuery("edit", "");
onMounted(() => {
lockScreen();
if (edit.value) {
state.form = edit.value === "true";
}
});
onUnmounted(() => {
unlockScreen();
});
// ===============================================================
// Check Before Leaving
const state = reactive({
form: false,
scale: 1,
scaleTemp: 1,
scaleDialog: false,
hideImage: false,
imageKey: 1,
skeleton: false,
jsonEditor: false,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
cookModeToggle: false,
});
const { recipe, loading, fetchRecipe } = useRecipe(slug);
// Manage a deep copy of the recipe so we can detect if changes have occurred and inform
// the user if they try to navigate away from the page without saving.
const originalRecipe = ref<Recipe | null>(null);
invoke(async () => {
await until(recipe).not.toBeNull();
originalRecipe.value = deepCopy(recipe.value);
});
const { recipeImage } = useStaticRoutes();
// ===========================================================================
// Layout Helpers
const { $vuetify } = useContext();
const enableLandscape = computed(() => {
const preferLandscape = recipe?.value?.settings?.landscapeView;
const smallScreen = !$vuetify.breakpoint.smAndUp;
if (preferLandscape) {
return true;
} else if (smallScreen) {
return true;
}
return false;
});
const imageHeight = computed(() => {
return $vuetify.breakpoint.xs ? "200" : "400";
});
// ===========================================================================
// Button Click Event Handlers
function toggleEdit() {
state.jsonEditor = false;
state.cookModeToggle = false;
state.form = true;
}
async function updateRecipe(slug: string, recipe: Recipe) {
const { data } = await api.recipes.updateOne(slug, recipe);
state.form = false;
state.jsonEditor = false;
if (data?.slug) {
router.push("/recipe/" + data.slug);
}
}
async function deleteRecipe(slug: string) {
const { data } = await api.recipes.deleteOne(slug);
if (data?.slug) {
router.push("/");
}
}
function printRecipe() {
window.print();
}
async function closeEditor() {
state.form = false;
state.jsonEditor = false;
await fetchRecipe();
}
function toggleJson() {
state.jsonEditor = !state.jsonEditor;
}
const scaledYield = computed(() => {
const regMatchNum = /\d+/;
const yieldString = recipe.value?.recipeYield;
const num = yieldString?.match(regMatchNum);
if (num && num?.length > 0) {
const yieldAsInt = parseInt(num[0]);
return yieldString?.replace(num[0], String(yieldAsInt * state.scale));
}
return recipe.value?.recipeYield;
});
const basicYield = computed(() => {
const regMatchNum = /\d+/;
const yieldString = recipe.value?.recipeYield;
const num = yieldString?.match(regMatchNum);
if (num && num?.length > 0) {
const yieldAsInt = parseInt(num[0]);
return yieldString?.replace(num[0], String(yieldAsInt));
}
return recipe.value?.recipeYield;
});
async function uploadImage(fileObject: File) {
if (!recipe.value || !recipe.value.slug) {
return;
}
const newVersion = await api.recipes.updateImage(recipe.value.slug, fileObject);
if (newVersion?.data?.image) {
recipe.value.image = newVersion.data.image;
}
state.imageKey++;
}
function addStep(steps: Array<string> | null = null) {
if (!recipe.value?.recipeInstructions) {
return;
}
if (steps) {
const cleanedSteps = steps.map((step) => {
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
});
recipe.value.recipeInstructions.push(...cleanedSteps);
} else {
recipe.value.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] });
}
}
function addIngredient(ingredients: Array<string> | null = null) {
if (ingredients?.length) {
const newIngredients = ingredients.map((x) => {
return {
referenceId: uuid4(),
title: "",
note: x,
unit: undefined,
food: undefined,
disableAmount: true,
quantity: 1,
};
});
if (newIngredients) {
recipe?.value?.recipeIngredient?.push(...newIngredients);
}
} else {
recipe?.value?.recipeIngredient?.push({
referenceId: uuid4(),
title: "",
note: "",
unit: undefined,
food: undefined,
disableAmount: true,
quantity: 1,
});
}
}
// ===============================================================
// Recipe Tools
const toolStore = useToolStore();
const apiNewKey = ref("");
function createApiExtra() {
if (!recipe.value) {
return;
}
if (!recipe.value.extras) {
recipe.value.extras = {};
}
// check for duplicate keys
if (Object.keys(recipe.value.extras).includes(apiNewKey.value)) {
return;
}
recipe.value.extras[apiNewKey.value] = "";
apiNewKey.value = "";
}
function removeApiExtra(key: string) {
if (!recipe.value) {
return;
}
if (!recipe.value.extras) {
return;
}
delete recipe.value.extras[key];
recipe.value.extras = { ...recipe.value.extras };
}
// ===============================================================
// Metadata
const metaData = useRecipeMeta(recipe);
useMeta(metaData);
const hasFoodOrUnit = computed(() => {
if (!recipe.value) {
return false;
}
if (recipe.value.recipeIngredient) {
for (const ingredient of recipe.value.recipeIngredient) {
if (ingredient.food || ingredient.unit) {
return true;
}
}
}
return false;
});
const paserToolTip = computed(() => {
if (recipe.value?.settings?.disableAmount) {
return "Enable ingredient amounts to use this feature";
} else if (hasFoodOrUnit.value) {
return "Recipes with units or foods defined cannot be parsed.";
}
return "Parse ingredients";
});
const drag = ref(false);
// ===============================================================
// Scale
const setScale = (newScale: number) => {
state.scale = newScale;
};
return {
// Wake Lock
drag,
wakeIsSupported,
isActive,
lockScreen,
unlockScreen,
wakeLock,
//
hasFoodOrUnit,
paserToolTip,
originalRecipe,
createApiExtra,
apiNewKey,
enableLandscape,
imageHeight,
scaledYield,
basicYield,
toggleJson,
...toRefs(state),
recipe,
api,
loading,
addStep,
setScale,
deleteRecipe,
printRecipe,
closeEditor,
toggleEdit,
updateRecipe,
uploadImage,
validators,
recipeImage,
addIngredient,
removeApiExtra,
toolStore,
fetchRecipe,
api,
};
},
head: {},
});
</script>
<style lang="css">
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
}
.list-group {
min-height: 38px;
}
.list-group-item {
cursor: move;
}
.list-group-item i {
cursor: pointer;
}
</style>
<style scoped></style>

View file

@ -0,0 +1,905 @@
<template>
<v-container
:class="{
'pa-0': $vuetify.breakpoint.smAndDown,
}"
>
<v-card v-if="skeleton" :color="`white ${false ? 'darken-2' : 'lighten-4'}`" class="pa-3">
<v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader>
</v-card>
<v-card v-else-if="recipe" :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
<!-- Recipe Header -->
<div class="d-flex justify-end flex-wrap align-stretch">
<v-card v-if="!enableLandscape" 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" :name="recipe.name" :slug="recipe.slug" />
</v-card-title>
<v-divider class="my-2"></v-divider>
<SafeMarkdown :source="recipe.description" />
<v-divider></v-divider>
<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="enableLandscape ? null : '50%'"
min-height="50"
:height="hideImage ? undefined : imageHeight"
:src="recipeImage(recipe.id, recipe.image, imageKey)"
class="d-print-none"
@error="hideImage = true"
>
</v-img>
</div>
<v-divider></v-divider>
<RecipeActionMenu
v-model="form"
:slug="recipe.slug"
:locked="$auth.user.id !== recipe.userId && recipe.settings.locked"
:name="recipe.name"
:logged-in="$auth.loggedIn"
:open="form"
:recipe-id="recipe.id"
class="ml-auto mt-n8 pb-4"
@close="closeEditor"
@json="toggleJson"
@edit="toggleEdit"
@save="updateRecipe(recipe.slug, recipe)"
@delete="deleteRecipe(recipe.slug)"
@print="printRecipe"
/>
<!-- Editors -->
<LazyRecipeJsonEditor v-if="jsonEditor" v-model="recipe" class="mt-10" :options="jsonEditorOptions" />
<div v-else>
<v-card-text
:class="{
'px-2': $vuetify.breakpoint.smAndDown,
}"
>
<div v-if="form" class="d-flex justify-start align-center">
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" />
<RecipeSettingsMenu
class="my-1 mx-1"
:value="recipe.settings"
:is-owner="recipe.userId == $auth.user.id"
@upload="uploadImage"
/>
</div>
<!-- Recipe Title Section -->
<template v-if="!form && enableLandscape">
<v-card-title class="px-0 py-2 ma-0 headline">
{{ recipe.name }}
</v-card-title>
<SafeMarkdown :source="recipe.description" />
<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="enableLandscape && $vuetify.breakpoint.smAndDown"
:key="recipe.slug"
:value="recipe.rating"
:name="recipe.name"
:slug="recipe.slug"
/>
</div>
<v-divider></v-divider>
</template>
<template v-else-if="form">
<v-text-field
v-model="recipe.name"
class="my-3"
:label="$t('recipe.recipe-name')"
:rules="[validators.required]"
>
</v-text-field>
<div class="d-flex flex-wrap">
<v-text-field v-model="recipe.totalTime" class="mx-2" :label="$t('recipe.total-time')"></v-text-field>
<v-text-field v-model="recipe.prepTime" class="mx-2" :label="$t('recipe.prep-time')"></v-text-field>
<v-text-field v-model="recipe.performTime" class="mx-2" :label="$t('recipe.perform-time')"></v-text-field>
</div>
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')">
</v-textarea>
<v-text-field v-model="recipe.recipeYield" dense :label="$t('recipe.servings')"> </v-text-field>
</template>
<!-- Advanced Editor -->
<div v-if="form">
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<draggable
v-if="recipe.recipeIngredient.length > 0"
v-model="recipe.recipeIngredient"
handle=".handle"
v-bind="{
animation: 200,
group: 'description',
disabled: false,
ghostClass: 'ghost',
}"
@start="drag = true"
@end="drag = false"
>
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
<RecipeIngredientEditor
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
class="list-group-item"
:disable-amount="recipe.settings.disableAmount"
@delete="recipe.recipeIngredient.splice(index, 1)"
/>
</TransitionGroup>
</draggable>
<v-skeleton-loader v-else boilerplate elevation="2" type="list-item"> </v-skeleton-loader>
<div class="d-flex justify-end mt-2">
<v-tooltip top color="accent">
<template #activator="{ on, attrs }">
<span v-on="on">
<BaseButton
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
color="accent"
:to="`${recipe.slug}/ingredient-parser`"
v-bind="attrs"
>
<template #icon>
{{ $globals.icons.foods }}
</template>
Parse
</BaseButton>
</span>
</template>
<span>{{ paserToolTip }}</span>
</v-tooltip>
<RecipeDialogBulkAdd class="ml-1 mr-1" @bulk-data="addIngredient" />
<BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton>
</div>
</div>
<div class="d-flex justify-space-between align-center pt-2 pb-3">
<v-tooltip v-if="!form && recipe.recipeYield" small top color="secondary darken-1">
<template #activator="{ on, attrs }">
<RecipeScaleEditButton
v-model.number="scale"
v-bind="attrs"
:recipe-yield="recipe.recipeYield"
:basic-yield="basicYield"
:scaled-yield="scaledYield"
:edit-scale="!recipe.settings.disableAmount && !form"
v-on="on"
/>
</template>
<span> {{ $t("recipe.edit-scale") }} </span>
</v-tooltip>
<v-spacer></v-spacer>
<RecipeRating
v-if="enableLandscape && $vuetify.breakpoint.smAndUp"
:key="recipe.slug"
:value="recipe.rating"
:name="recipe.name"
:slug="recipe.slug"
/>
</div>
<v-row>
<v-col v-if="!cookModeToggle || form" cols="12" sm="12" md="4" lg="4">
<RecipeIngredients
v-if="!form"
:value="recipe.recipeIngredient"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
/>
<!-- Recipe Tools Display -->
<div v-if="!form && recipe.tools && recipe.tools.length > 0">
<h2 class="mb-2 mt-4">Required Tools</h2>
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
<v-checkbox
v-model="recipe.tools[index].onHand"
hide-details
class="pt-0 my-auto py-auto"
color="secondary"
@change="toolStore.actions.updateOne(recipe.tools[index])"
>
</v-checkbox>
<v-list-item-content>
{{ tool.name }}
</v-list-item-content>
</v-list-item>
</div>
<div v-if="$vuetify.breakpoint.mdAndUp" class="mt-5">
<!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.recipeCategory"
:return-object="true"
:show-add="true"
selector-type="categories"
/>
<RecipeChips v-else :items="recipe.recipeCategory" />
</v-card-text>
</v-card>
<!-- Recipe Tags -->
<v-card v-if="recipe.tags.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.tags"
:return-object="true"
:show-add="true"
selector-type="tags"
/>
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" />
</v-card-text>
</v-card>
<!-- Recipe Tools Edit -->
<v-card v-if="form" class="mt-2">
<v-card-title class="py-2"> Required Tools </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text class="pt-0">
<RecipeOrganizerSelector v-model="recipe.tools" selector-type="tools" />
</v-card-text>
</v-card>
<RecipeNutrition
v-if="recipe.settings.showNutrition"
v-model="recipe.nutrition"
class="mt-10"
:edit="form"
/>
<client-only>
<RecipeAssets
v-if="recipe.settings.showAssets"
v-model="recipe.assets"
:edit="form"
:slug="recipe.slug"
:recipe-id="recipe.id"
/>
</client-only>
</div>
</v-col>
<v-divider
v-if="$vuetify.breakpoint.mdAndUp && !cookModeToggle"
class="my-divider"
:vertical="true"
></v-divider>
<v-col cols="12" sm="12" :md="8 + (cookModeToggle ? 1 : 0) * 4" :lg="8 + (cookModeToggle ? 1 : 0) * 4">
<RecipeInstructions
v-model="recipe.recipeInstructions"
:assets.sync="recipe.assets"
:ingredients="recipe.recipeIngredient"
:disable-amount="recipe.settings.disableAmount"
:edit="form"
:recipe-id="recipe.id"
:recipe-slug="recipe.slug"
:cook-mode="cookModeToggle"
:scale="scale"
@cookModeToggle="cookModeToggle = !cookModeToggle"
/>
<div v-if="form" class="d-flex">
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
</div>
<!-- TODO: Somehow fix duplicate code for mobile/desktop -->
<div v-if="!$vuetify.breakpoint.mdAndUp" class="mt-5">
<!-- Recipe Tools Edit -->
<v-card v-if="form">
<v-card-title class="py-2"> Required Tools</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text class="pt-0">
<RecipeTools v-model="recipe.tools" :edit="form" />
</v-card-text>
</v-card>
<!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.recipeCategory"
:return-object="true"
:show-add="true"
selector-type="categories"
/>
<RecipeChips v-else :items="recipe.recipeCategory" />
</v-card-text>
</v-card>
<!-- Recipe Tags -->
<v-card v-if="recipe.tags.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.tags"
:return-object="true"
:show-add="true"
selector-type="tags"
/>
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" />
</v-card-text>
</v-card>
<RecipeNutrition
v-if="recipe.settings.showNutrition && !cookModeToggle"
v-model="recipe.nutrition"
class="mt-10"
:edit="form"
/>
<client-only>
<RecipeAssets
v-if="recipe.settings.showAssets && !cookModeToggle"
v-model="recipe.assets"
:edit="form"
:slug="recipe.slug"
:recipe-id="recipe.id"
/>
</client-only>
</div>
<RecipeNotes v-if="!cookModeToggle" v-model="recipe.notes" :edit="form" />
</v-col>
</v-row>
<v-card-actions class="justify-end">
<v-text-field
v-if="form"
v-model="recipe.orgURL"
class="mt-10"
:label="$t('recipe.original-url')"
></v-text-field>
<v-btn
v-else-if="recipe.orgURL && !cookModeToggle"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
:href="recipe.orgURL"
color="secondary darken-1"
target="_blank"
class="rounded-sm mr-4"
>
{{ $t("recipe.original-url") }}
</v-btn>
</v-card-actions>
</v-card-text>
</div>
<v-card v-if="form && $auth.user.advanced" flat class="ma-2 mb-2">
<v-card-title> API Extras </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
Recipes extras are a key feature of the Mealie API. They allow you to create custom json key/value pairs
within a recipe to reference from 3rd part applications. You can use these keys to contain information to
trigger automation or custom messages to relay to your desired device.
<v-row v-for="(value, key) in recipe.extras" :key="key" class="mt-1">
<v-col cols="8">
<v-text-field v-model="recipe.extras[key]" dense :label="key">
<template #prepend>
<v-btn color="error" icon class="mt-n4" @click="removeApiExtra(key)">
<v-icon> {{ $globals.icons.delete }} </v-icon>
</v-btn>
</template>
</v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex">
<div style="max-width: 200px">
<v-text-field v-model="apiNewKey" label="Message Key"></v-text-field>
</div>
<BaseButton create small class="ml-5" @click="createApiExtra" />
</v-card-actions>
</v-card>
</v-card>
<div
v-if="recipe && wakeIsSupported"
class="d-print-none d-flex px-2"
:class="$vuetify.breakpoint.smAndDown ? 'justify-center' : 'justify-end'"
>
<v-switch v-model="wakeLock" small label="Keep Screen Awake" />
</div>
<RecipeComments
v-if="recipe && !recipe.settings.disableComments && !form && !cookModeToggle"
v-model="recipe.comments"
:slug="recipe.slug"
:recipe-id="recipe.id"
class="px-1 my-4 d-print-none"
/>
<RecipePrintView v-if="recipe" :recipe="recipe" />
</v-container>
</template>
<script lang="ts">
import {
computed,
defineComponent,
reactive,
ref,
toRefs,
useContext,
useMeta,
useRoute,
useRouter,
onMounted,
} from "@nuxtjs/composition-api";
import draggable from "vuedraggable";
import { invoke, until, useWakeLock } from "@vueuse/core";
import { onUnmounted } from "vue-demi";
import RecipeOrganizerSelector from "@/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import RecipeDialogBulkAdd from "@/components/Domain/Recipe//RecipeDialogBulkAdd.vue";
import { useUserApi, useStaticRoutes } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { useRecipe, useRecipeMeta } from "~/composables/recipes";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import RecipeChips from "~/components/Domain/Recipe/RecipeChips.vue";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
import RecipeInstructions from "~/components/Domain/Recipe/RecipeInstructions.vue";
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue";
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import RecipeTools from "~/components/Domain/Recipe/RecipeTools.vue";
import RecipeComments from "~/components/Domain/Recipe/RecipeComments.vue";
import { Recipe } from "~/types/api-types/recipe";
import { uuid4, deepCopy } from "~/composables/use-utils";
import { useRouteQuery } from "~/composables/use-router";
import { useToolStore } from "~/composables/store";
export default defineComponent({
components: {
draggable,
RecipeActionMenu,
RecipeAssets: () => {
if (process.client) {
return import(/* webpackChunkName: "RecipeAssets" */ "~/components/Domain/Recipe/RecipeAssets.vue");
}
},
RecipeOrganizerSelector,
RecipeChips,
RecipeComments,
RecipeDialogBulkAdd,
RecipeImageUploadBtn,
RecipeIngredientEditor,
RecipeIngredients,
RecipeInstructions,
RecipeNotes,
RecipeNutrition,
RecipePrintView,
RecipeRating,
RecipeSettingsMenu,
RecipeTimeCard,
RecipeTools,
RecipeScaleEditButton,
},
async beforeRouteLeave(_to, _from, next) {
const isSame = JSON.stringify(this.recipe) === JSON.stringify(this.originalRecipe);
if (this.form && !isSame && this.recipe?.slug !== undefined) {
if (
window.confirm(
"You have unsaved changes. Do you want to save before leaving?\n\nOkay to save, Cancel to discard changes."
)
) {
await this.api.recipes.updateOne(this.recipe.slug, this.recipe);
}
}
next();
},
setup() {
const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug;
const api = useUserApi();
// ===============================================================
// Screen Lock
const { isSupported: wakeIsSupported, isActive, request, release } = useWakeLock();
const wakeLock = computed({
get: () => isActive,
set: () => {
if (isActive.value) {
unlockScreen();
} else {
lockScreen();
}
},
});
async function lockScreen() {
if (wakeIsSupported) {
console.log("Wake Lock Requested");
await request("screen");
}
}
async function unlockScreen() {
if (wakeIsSupported || isActive) {
console.log("Wake Lock Released");
await release();
}
}
// ===============================================================
// Edit on Navigate
const edit = useRouteQuery("edit", "");
onMounted(() => {
lockScreen();
if (edit.value) {
state.form = edit.value === "true";
}
});
onUnmounted(() => {
unlockScreen();
});
// ===============================================================
// Check Before Leaving
const state = reactive({
form: false,
scale: 1,
scaleTemp: 1,
scaleDialog: false,
hideImage: false,
imageKey: 1,
skeleton: false,
jsonEditor: false,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
cookModeToggle: false,
});
const { recipe, loading, fetchRecipe } = useRecipe(slug);
// Manage a deep copy of the recipe so we can detect if changes have occurred and inform
// the user if they try to navigate away from the page without saving.
const originalRecipe = ref<Recipe | null>(null);
invoke(async () => {
await until(recipe).not.toBeNull();
originalRecipe.value = deepCopy(recipe.value);
});
const { recipeImage } = useStaticRoutes();
// ===========================================================================
// Layout Helpers
const { $vuetify } = useContext();
const enableLandscape = computed(() => {
const preferLandscape = recipe?.value?.settings?.landscapeView;
const smallScreen = !$vuetify.breakpoint.smAndUp;
if (preferLandscape) {
return true;
} else if (smallScreen) {
return true;
}
return false;
});
const imageHeight = computed(() => {
return $vuetify.breakpoint.xs ? "200" : "400";
});
// ===========================================================================
// Button Click Event Handlers
function toggleEdit() {
state.jsonEditor = false;
state.cookModeToggle = false;
state.form = true;
}
async function updateRecipe(slug: string, recipe: Recipe) {
const { data } = await api.recipes.updateOne(slug, recipe);
state.form = false;
state.jsonEditor = false;
if (data?.slug) {
router.push("/recipe/" + data.slug);
}
}
async function deleteRecipe(slug: string) {
const { data } = await api.recipes.deleteOne(slug);
if (data?.slug) {
router.push("/");
}
}
function printRecipe() {
window.print();
}
async function closeEditor() {
state.form = false;
state.jsonEditor = false;
await fetchRecipe();
}
function toggleJson() {
state.jsonEditor = !state.jsonEditor;
}
const scaledYield = computed(() => {
const regMatchNum = /\d+/;
const yieldString = recipe.value?.recipeYield;
const num = yieldString?.match(regMatchNum);
if (num && num?.length > 0) {
const yieldAsInt = parseInt(num[0]);
return yieldString?.replace(num[0], String(yieldAsInt * state.scale));
}
return recipe.value?.recipeYield;
});
const basicYield = computed(() => {
const regMatchNum = /\d+/;
const yieldString = recipe.value?.recipeYield;
const num = yieldString?.match(regMatchNum);
if (num && num?.length > 0) {
const yieldAsInt = parseInt(num[0]);
return yieldString?.replace(num[0], String(yieldAsInt));
}
return recipe.value?.recipeYield;
});
async function uploadImage(fileObject: File) {
if (!recipe.value || !recipe.value.slug) {
return;
}
const newVersion = await api.recipes.updateImage(recipe.value.slug, fileObject);
if (newVersion?.data?.image) {
recipe.value.image = newVersion.data.image;
}
state.imageKey++;
}
function addStep(steps: Array<string> | null = null) {
if (!recipe.value?.recipeInstructions) {
return;
}
if (steps) {
const cleanedSteps = steps.map((step) => {
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
});
recipe.value.recipeInstructions.push(...cleanedSteps);
} else {
recipe.value.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] });
}
}
function addIngredient(ingredients: Array<string> | null = null) {
if (ingredients?.length) {
const newIngredients = ingredients.map((x) => {
return {
referenceId: uuid4(),
title: "",
note: x,
unit: undefined,
food: undefined,
disableAmount: true,
quantity: 1,
};
});
if (newIngredients) {
recipe?.value?.recipeIngredient?.push(...newIngredients);
}
} else {
recipe?.value?.recipeIngredient?.push({
referenceId: uuid4(),
title: "",
note: "",
unit: undefined,
food: undefined,
disableAmount: true,
quantity: 1,
});
}
}
// ===============================================================
// Recipe Tools
const toolStore = useToolStore();
const apiNewKey = ref("");
function createApiExtra() {
if (!recipe.value) {
return;
}
if (!recipe.value.extras) {
recipe.value.extras = {};
}
// check for duplicate keys
if (Object.keys(recipe.value.extras).includes(apiNewKey.value)) {
return;
}
recipe.value.extras[apiNewKey.value] = "";
apiNewKey.value = "";
}
function removeApiExtra(key: string) {
if (!recipe.value) {
return;
}
if (!recipe.value.extras) {
return;
}
delete recipe.value.extras[key];
recipe.value.extras = { ...recipe.value.extras };
}
// ===============================================================
// Metadata
const metaData = useRecipeMeta(recipe);
useMeta(metaData);
const hasFoodOrUnit = computed(() => {
if (!recipe.value) {
return false;
}
if (recipe.value.recipeIngredient) {
for (const ingredient of recipe.value.recipeIngredient) {
if (ingredient.food || ingredient.unit) {
return true;
}
}
}
return false;
});
const paserToolTip = computed(() => {
if (recipe.value?.settings?.disableAmount) {
return "Enable ingredient amounts to use this feature";
} else if (hasFoodOrUnit.value) {
return "Recipes with units or foods defined cannot be parsed.";
}
return "Parse ingredients";
});
const drag = ref(false);
// ===============================================================
// Scale
const setScale = (newScale: number) => {
state.scale = newScale;
};
return {
// Wake Lock
drag,
wakeIsSupported,
isActive,
lockScreen,
unlockScreen,
wakeLock,
//
hasFoodOrUnit,
paserToolTip,
originalRecipe,
createApiExtra,
apiNewKey,
enableLandscape,
imageHeight,
scaledYield,
basicYield,
toggleJson,
...toRefs(state),
recipe,
api,
loading,
addStep,
setScale,
deleteRecipe,
printRecipe,
closeEditor,
toggleEdit,
updateRecipe,
uploadImage,
validators,
recipeImage,
addIngredient,
removeApiExtra,
toolStore,
};
},
head: {},
});
</script>
<style lang="css">
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
}
.list-group {
min-height: 38px;
}
.list-group-item {
cursor: move;
}
.list-group-item i {
cursor: pointer;
}
</style>

View file

@ -1,5 +1,7 @@
import { AxiosResponse } from "axios";
export type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> };
export interface RequestResponse<T> {
response: AxiosResponse<T> | null;
data: T | null;

View file

@ -1,4 +1,4 @@
declare module "*.vue" {
import Vue from "vue"
export default Vue
import Vue from "vue";
export default Vue;
}

27
template.vue Normal file
View file

@ -0,0 +1,27 @@
<template>
<div></div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/types/api";
import { Recipe } from "~/types/api-types/recipe";
export default defineComponent({
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { user } = usePageUser();
const { imageKey } = usePageState(props.recipe.slug);
return {
user,
imageKey,
};
},
});
</script>