mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-02-25 03:17:23 -08:00
295 lines
9.7 KiB
Vue
295 lines
9.7 KiB
Vue
<template>
|
|
<v-container :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
|
|
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
|
|
<RecipePageHeader
|
|
:recipe="recipe"
|
|
:recipe-scale="scale"
|
|
:landscape="landscape"
|
|
@save="saveRecipe"
|
|
@delete="deleteRecipe"
|
|
/>
|
|
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" />
|
|
<v-card-text v-else>
|
|
<!--
|
|
This is where most of the main content is rendered. Some components include state for both Edit and View modes
|
|
which is why some have explicit v-if statements and others use the composition API to determine and manage
|
|
the shared state internally.
|
|
|
|
The global recipe object is shared down the tree of components and _is_ mutated by child components. This is
|
|
some-what of a hack of the system and goes against the principles of Vue, but it _does_ seem to work and streamline
|
|
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
|
|
data management and mutation system we're using.
|
|
-->
|
|
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" />
|
|
<RecipePageTitleContent :recipe="recipe" :landscape="landscape" />
|
|
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
|
|
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape" />
|
|
|
|
<!--
|
|
This section contains the 2 column layout for the recipe steps and other content.
|
|
-->
|
|
<v-row>
|
|
<!--
|
|
The left column is conditionally rendered based on cook mode.
|
|
-->
|
|
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
|
|
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
|
|
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" />
|
|
</v-col>
|
|
<v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
|
|
|
|
<!--
|
|
the right column is always rendered, but it's layout width is determined by where the left column is
|
|
rendered.
|
|
-->
|
|
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
|
|
<RecipePageInstructions
|
|
v-model="recipe.recipeInstructions"
|
|
:assets.sync="recipe.assets"
|
|
:recipe="recipe"
|
|
:scale="scale"
|
|
/>
|
|
<div v-if="isEditForm" class="d-flex">
|
|
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
|
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.add") }}</BaseButton>
|
|
</div>
|
|
<div v-if="!$vuetify.breakpoint.mdAndUp">
|
|
<RecipePageOrganizers :recipe="recipe" />
|
|
</div>
|
|
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" />
|
|
</v-col>
|
|
</v-row>
|
|
<RecipePageFooter :recipe="recipe" />
|
|
</v-card-text>
|
|
</v-card>
|
|
<WakelockSwitch/>
|
|
<RecipePageComments
|
|
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
|
|
:recipe="recipe"
|
|
class="px-1 my-4 d-print-none"
|
|
/>
|
|
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
|
</v-container>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import {
|
|
defineComponent,
|
|
useContext,
|
|
useRouter,
|
|
computed,
|
|
ref,
|
|
onMounted,
|
|
onUnmounted,
|
|
useRoute,
|
|
} from "@nuxtjs/composition-api";
|
|
import { invoke, until } from "@vueuse/core";
|
|
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 { useLoggedInState } from "~/composables/use-logged-in-state";
|
|
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
|
|
import { EditorMode, PageMode, usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
|
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
|
import { Recipe } from "~/lib/api/types/recipe";
|
|
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";
|
|
import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
|
|
|
const EDITOR_OPTIONS = {
|
|
mode: "code",
|
|
search: false,
|
|
mainMenuBar: false,
|
|
};
|
|
|
|
export default defineComponent({
|
|
components: {
|
|
RecipePageHeader,
|
|
RecipePrintContainer,
|
|
RecipePageComments,
|
|
RecipePageTitleContent,
|
|
RecipePageEditorToolbar,
|
|
RecipePageIngredientEditor,
|
|
RecipePageOrganizers,
|
|
RecipePageScale,
|
|
RecipePageIngredientToolsView,
|
|
RecipeDialogBulkAdd,
|
|
RecipeNotes,
|
|
RecipePageInstructions,
|
|
RecipePageFooter,
|
|
},
|
|
props: {
|
|
recipe: {
|
|
type: Object as () => NoUndefinedField<Recipe>,
|
|
required: true,
|
|
},
|
|
},
|
|
setup(props) {
|
|
const { $auth } = useContext();
|
|
const route = useRoute();
|
|
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
|
const { isOwnGroup } = useLoggedInState();
|
|
|
|
const router = useRouter();
|
|
const api = useUserApi();
|
|
const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } =
|
|
usePageState(props.recipe.slug);
|
|
const { deactivateNavigationWarning } = useNavigationWarning();
|
|
|
|
/** =============================================================
|
|
* 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(
|
|
i18n.tc("general.unsaved-changes"),
|
|
);
|
|
|
|
if (save) {
|
|
await api.recipes.updateOne(props.recipe.slug, props.recipe);
|
|
}
|
|
}
|
|
deactivateNavigationWarning();
|
|
});
|
|
|
|
/** =============================================================
|
|
* Set State onMounted
|
|
*/
|
|
|
|
type BooleanString = "true" | "false" | "";
|
|
|
|
const edit = useRouteQuery<BooleanString>("edit", "");
|
|
|
|
onMounted(() => {
|
|
if (edit.value === "true") {
|
|
setMode(PageMode.EDIT);
|
|
}
|
|
});
|
|
|
|
/** =============================================================
|
|
* 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(`/g/${groupSlug.value}/r/` + data.slug);
|
|
}
|
|
}
|
|
|
|
async function deleteRecipe() {
|
|
const { data } = await api.recipes.deleteOne(props.recipe.slug);
|
|
if (data?.slug) {
|
|
router.push(`/g/${groupSlug.value}`);
|
|
}
|
|
}
|
|
|
|
/** =============================================================
|
|
* View Preferences
|
|
*/
|
|
const { $vuetify, i18n } = 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 { user } = usePageUser();
|
|
|
|
return {
|
|
user,
|
|
isOwnGroup,
|
|
api,
|
|
scale: ref(1),
|
|
EDITOR_OPTIONS,
|
|
landscape,
|
|
|
|
pageMode,
|
|
editMode,
|
|
PageMode,
|
|
EditorMode,
|
|
isEditMode,
|
|
isEditForm,
|
|
isEditJSON,
|
|
isCookMode,
|
|
toggleCookMode,
|
|
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 i {
|
|
cursor: pointer;
|
|
}
|
|
</style>
|