feat: Migrate to Nuxt 3 framework (#5184)

Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Hoa (Kyle) Trinh
2025-06-20 00:09:12 +07:00
committed by GitHub
parent 89ab7fac25
commit c24d532608
403 changed files with 23959 additions and 19557 deletions

View File

@@ -6,44 +6,73 @@
</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 :tooltip="false" size="40" :user-id="user.id" />
<v-divider class="mx-2" />
<div
v-if="user.id"
class="d-flex flex-column"
>
<div
class="d-flex mt-3"
style="gap: 10px"
>
<UserAvatar
:tooltip="false"
size="40"
:user-id="user.id"
/>
<v-textarea
v-model="comment"
hide-details=""
dense
hide-details
density="compact"
single-line
outlined
variant="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>
<BaseButton
size="small"
:disabled="!comment"
@click="submitComment"
>
<template #icon>
{{ $globals.icons.check }}
</template>
{{ $t("general.submit") }}
</BaseButton>
</div>
</div>
<div v-for="comment in recipe.comments" :key="comment.id" class="d-flex my-2" style="gap: 10px">
<UserAvatar :tooltip="false" size="40" :user-id="comment.userId" />
<v-card outlined class="flex-grow-1">
<div
v-for="recipeComment in recipe.comments"
:key="recipeComment.id"
class="d-flex my-2"
style="gap: 10px"
>
<UserAvatar
:tooltip="false"
size="40"
:user-id="recipeComment.userId"
/>
<v-card
variant="outlined"
class="flex-grow-1"
>
<v-card-text class="pa-3 pb-0">
<p class="">{{ comment.user.fullName }} {{ $d(Date.parse(comment.createdAt), "medium") }}</p>
<SafeMarkdown :source="comment.text" />
<p class="">
{{ recipeComment.user.fullName }} {{ $d(Date.parse(recipeComment.createdAt), "medium") }}
</p>
<SafeMarkdown :source="recipeComment.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"
v-if="user.id == recipeComment.user.id || user.admin"
color="error"
text
x-small
@click="deleteComment(comment.id)"
variant="text"
size="x-small"
@click="deleteComment(recipeComment.id)"
>
{{ $t("general.delete") }}
</v-btn>
@@ -53,58 +82,37 @@
</div>
</template>
<script lang="ts">
import { defineComponent, toRefs, reactive } from "@nuxtjs/composition-api";
<script lang="ts" setup>
import { useUserApi } from "~/composables/api";
import { Recipe } from "~/lib/api/types/recipe";
import type { Recipe } from "~/lib/api/types/recipe";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { usePageUser } from "~/composables/recipe-page/shared-state";
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
export default defineComponent({
components: {
UserAvatar,
SafeMarkdown
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const api = useUserApi();
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const api = useUserApi();
const { user } = usePageUser();
const comment = ref("");
const { user } = usePageUser();
async function submitComment() {
const { data } = await api.recipes.comments.createOne({
recipeId: recipe.value.id,
text: comment.value,
});
const state = reactive({
comment: "",
});
if (data) {
recipe.value.comments.push(data);
}
async function submitComment() {
const { data } = await api.recipes.comments.createOne({
recipeId: props.recipe.id,
text: state.comment,
});
comment.value = "";
}
if (data) {
// @ts-ignore username is always populated here
props.recipe.comments.push(data);
}
async function deleteComment(id: string) {
const { response } = await api.recipes.comments.deleteOne(id);
state.comment = "";
}
async function deleteComment(id: string) {
const { response } = await api.recipes.comments.deleteOne(id);
if (response?.status === 200) {
props.recipe.comments = props.recipe.comments.filter((comment) => comment.id !== id);
}
}
return { api, ...toRefs(state), submitComment, deleteComment, user };
},
});
if (response?.status === 200) {
recipe.value.comments = recipe.value.comments.filter(comment => comment.id !== id);
}
}
</script>

View File

@@ -1,28 +1,44 @@
<template>
<div class="d-flex justify-start align-top py-2">
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" />
<RecipeImageUploadBtn
class="my-1"
:slug="recipe.slug"
@upload="uploadImage"
@refresh="imageKey++"
/>
<RecipeSettingsMenu
v-model="recipe.settings"
class="my-1 mx-1"
:value="recipe.settings"
:is-owner="recipe.userId == user.id"
@upload="uploadImage"
/>
<v-spacer />
<v-container class="py-0" style="width: 40%;">
<v-container
class="py-0"
style="width: 40%;"
>
<v-select
v-model="recipe.userId"
:items="allUsers"
item-text="fullName"
item-title="fullName"
item-value="id"
:label="$tc('general.owner')"
:label="$t('general.owner')"
hide-details
:disabled="!canEditOwner"
variant="underlined"
>
<template #prepend>
<UserAvatar :user-id="recipe.userId" :tooltip="false" />
<UserAvatar
:user-id="recipe.userId"
:tooltip="false"
/>
</template>
</v-select>
<v-card-text v-if="ownerHousehold" class="pa-0 d-flex" style="align-items: flex-end;">
<v-card-text
v-if="ownerHousehold"
class="pa-0 d-flex"
style="align-items: flex-end;"
>
<v-spacer />
<v-icon>{{ $globals.icons.household }}</v-icon>
<span class="pl-1">{{ ownerHousehold.name }}</span>
@@ -31,11 +47,11 @@
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
<script setup lang="ts">
import { computed } from "vue";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api";
import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue";
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
@@ -43,57 +59,34 @@ import { useUserStore } from "~/composables/store/use-user-store";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { useHouseholdStore } from "~/composables/store";
export default defineComponent({
components: {
RecipeImageUploadBtn,
RecipeSettingsMenu,
UserAvatar,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { user } = usePageUser();
const api = useUserApi();
const { imageKey } = usePageState(props.recipe.slug);
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const canEditOwner = computed(() => {
return user.id === props.recipe.userId || user.admin;
})
const { user } = usePageUser();
const api = useUserApi();
const { imageKey } = usePageState(recipe.value.slug);
const { store: allUsers } = useUserStore();
const { store: households } = useHouseholdStore();
const ownerHousehold = computed(() => {
const owner = allUsers.value.find((u) => u.id === props.recipe.userId);
if (!owner) {
return null;
};
return households.value.find((h) => h.id === owner.householdId);
});
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,
canEditOwner,
uploadImage,
imageKey,
allUsers,
ownerHousehold,
};
},
const canEditOwner = computed(() => {
return user.id === recipe.value.userId || user.admin;
});
const { store: allUsers } = useUserStore();
const { store: households } = useHouseholdStore();
const ownerHousehold = computed(() => {
const owner = allUsers.value.find(u => u.id === recipe.value.userId);
if (!owner) {
return null;
}
return households.value.find(h => h.id === owner.householdId);
});
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;
}
imageKey.value++;
}
</script>

View File

@@ -5,35 +5,54 @@
v-if="isEditForm"
v-model="recipe.orgURL"
class="mt-10"
variant="underlined"
: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"
variant="flat"
:href="recipe.orgURL"
color="secondary darken-1"
color="secondary-darken-1"
target="_blank"
class="rounded-sm mr-n2"
class="mr-n2"
size="small"
>
{{ $t("recipe.original-url") }}
</v-btn>
</v-card-actions>
<AdvancedOnly>
<v-card v-if="isEditForm" flat class="mb-2 mx-n2">
<v-card-title> {{ $t('recipe.api-extras') }} </v-card-title>
<v-divider class="ml-4"></v-divider>
<v-card
v-if="isEditForm"
flat
class="mb-2 mx-n2"
>
<v-card-title class="text-h5 font-weight-medium opacity-80">
{{ $t('recipe.api-extras') }}
</v-card-title>
<v-divider class="ml-4" />
<v-card-text>
{{ $t('recipe.api-extras-description') }}
<v-row v-for="(_, key) in recipe.extras" :key="key" class="mt-1">
<v-row
v-for="(_, key) in recipe.extras"
:key="key"
class="mt-1"
>
<v-col style="max-width: 400px;">
<v-text-field v-model="recipe.extras[key]" dense :label="key">
<v-text-field
v-model="recipe.extras[key]"
density="compact"
variant="underlined"
:label="key"
>
<template #prepend>
<v-btn color="error" icon class="mt-n4" @click="removeApiExtra(key)">
<v-btn
color="error"
icon
class="mt-n4"
@click="removeApiExtra(key)"
>
<v-icon> {{ $globals.icons.delete }} </v-icon>
</v-btn>
</template>
@@ -43,69 +62,58 @@
</v-card-text>
<v-card-actions class="d-flex ml-2 mt-n3">
<div>
<v-text-field v-model="apiNewKey" :label="$t('recipe.message-key')"></v-text-field>
<v-text-field
v-model="apiNewKey"
min-width="200px"
:label="$t('recipe.message-key')"
variant="underlined"
/>
</div>
<BaseButton create small class="ml-5" @click="createApiExtra" />
<BaseButton
create
size="small"
class="ml-5"
@click="createApiExtra"
/>
</v-card-actions>
</v-card>
</AdvancedOnly>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
<script setup lang="ts">
import { usePageState } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
export default defineComponent({
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { isEditForm, isCookMode } = usePageState(props.recipe.slug);
const apiNewKey = ref("");
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
function createApiExtra() {
if (!props.recipe) {
return;
}
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const { isEditForm, isCookMode } = usePageState(recipe.value.slug);
const apiNewKey = ref("");
if (!props.recipe.extras) {
props.recipe.extras = {};
}
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 = "";
}
// 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,
};
},
});
function removeApiExtra(key: string | number) {
if (!recipe.value) {
return;
}
if (!recipe.value.extras) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete recipe.value.extras[key];
recipe.value.extras = { ...recipe.value.extras };
}
</script>

View File

@@ -1,6 +1,10 @@
<template>
<div>
<RecipePageInfoCard :recipe="recipe" :recipe-scale="recipeScale" :landscape="landscape" />
<RecipePageInfoCard
:recipe="recipe"
:recipe-scale="recipeScale"
:landscape="landscape"
/>
<v-divider />
<RecipeActionMenu
:recipe="recipe"
@@ -11,7 +15,7 @@
:logged-in="isOwnGroup"
:open="isEditMode"
:recipe-id="recipe.id"
class="ml-auto mt-n2 pb-4"
class="ml-auto mt-n7 pb-4"
@close="setMode(PageMode.VIEW)"
@json="toggleEditMode()"
@edit="setMode(PageMode.EDIT)"
@@ -23,17 +27,17 @@
</template>
<script lang="ts">
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useRecipePermissions } from "~/composables/recipes";
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import { useStaticRoutes, useUserApi } from "~/composables/api";
import { HouseholdSummary } from "~/lib/api/types/household";
import { Recipe } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useStaticRoutes, useUserApi } from "~/composables/api";
import type { HouseholdSummary } from "~/lib/api/types/household";
import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
export default defineComponent({
export default defineNuxtComponent({
components: {
RecipePageInfoCard,
RecipeActionMenu,
@@ -52,8 +56,9 @@ export default defineComponent({
default: false,
},
},
emits: ["save", "delete"],
setup(props) {
const { $vuetify } = useContext();
const { $vuetify } = useNuxtApp();
const { recipeImage } = useStaticRoutes();
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
@@ -74,7 +79,7 @@ export default defineComponent({
const hideImage = ref(false);
const imageHeight = computed(() => {
return $vuetify.breakpoint.xs ? "200" : "400";
return $vuetify.display.xs.value ? "200" : "400";
});
const recipeImageUrl = computed(() => {
@@ -85,7 +90,7 @@ export default defineComponent({
() => recipeImageUrl.value,
() => {
hideImage.value = false;
}
},
);
return {

View File

@@ -1,24 +1,36 @@
<template>
<div>
<div class="d-flex justify-end flex-wrap align-stretch">
<RecipePageInfoCardImage v-if="landscape" :recipe="recipe" />
<RecipePageInfoCardImage
v-if="landscape"
:recipe="recipe"
/>
<v-card
:width="landscape ? '100%' : '50%'"
flat
class="d-flex flex-column justify-center align-center"
>
<v-card-text>
<v-card-title class="headline pa-0 flex-column align-center">
<v-card-text class="w-100">
<v-card-title class="text-h5 font-weight-regular pa-0 d-flex flex-column align-center justify-center opacity-80">
{{ recipe.name }}
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
<RecipeRating
:key="recipe.slug"
:value="recipe.rating"
:recipe-id="recipe.id"
:slug="recipe.slug"
/>
</v-card-title>
<v-divider class="my-2" />
<SafeMarkdown :source="recipe.description" />
<SafeMarkdown :source="recipe.description" class="my-3" />
<v-divider v-if="recipe.description" />
<v-container class="d-flex flex-row flex-wrap justify-center">
<div class="mx-6">
<v-row no-gutters>
<v-col v-if="recipe.recipeYieldQuantity || recipe.recipeYield" cols="12" class="d-flex flex-wrap justify-center">
<v-col
v-if="recipe.recipeYieldQuantity || recipe.recipeYield"
cols="12"
class="d-flex flex-wrap justify-center"
>
<RecipeYield
:yield-quantity="recipe.recipeYieldQuantity"
:yield="recipe.recipeYield"
@@ -28,7 +40,10 @@
</v-col>
</v-row>
<v-row no-gutters>
<v-col cols="12" class="d-flex flex-wrap justify-center">
<v-col
cols="12"
class="d-flex flex-wrap justify-center"
>
<RecipeLastMade
v-if="isOwnGroup"
:recipe="recipe"
@@ -49,22 +64,27 @@
</v-container>
</v-card-text>
</v-card>
<RecipePageInfoCardImage v-if="!landscape" :recipe="recipe" max-width="50%" class="my-auto" />
<RecipePageInfoCardImage
v-if="!landscape"
:recipe="recipe"
max-width="50%"
class="my-auto"
/>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import RecipeYield from "~/components/Domain/Recipe/RecipeYield.vue";
import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue";
import { Recipe } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineComponent({
import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({
components: {
RecipeRating,
RecipeLastMade,
@@ -87,15 +107,11 @@ export default defineComponent({
},
},
setup() {
const { $vuetify } = useContext();
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
const { isOwnGroup } = useLoggedInState();
return {
isOwnGroup,
useMobile,
};
}
},
});
</script>

View File

@@ -3,6 +3,8 @@
:key="imageKey"
:max-width="maxWidth"
min-height="50"
cover
width="100%"
:height="hideImage ? undefined : imageHeight"
:src="recipeImageUrl"
class="d-print-none"
@@ -11,13 +13,13 @@
</template>
<script lang="ts">
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api";
import { HouseholdSummary } from "~/lib/api/types/household";
import { useStaticRoutes, useUserApi } from "~/composables/api";
import type { HouseholdSummary } from "~/lib/api/types/household";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { Recipe } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineComponent({
import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
@@ -29,7 +31,7 @@ export default defineComponent({
},
},
setup(props) {
const { $vuetify } = useContext();
const { $vuetify } = useNuxtApp();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
@@ -44,7 +46,7 @@ export default defineComponent({
const hideImage = ref(false);
const imageHeight = computed(() => {
return $vuetify.breakpoint.xs ? "200" : "400";
return $vuetify.display.xs.value ? "200" : "400";
});
const recipeImageUrl = computed(() => {
@@ -55,7 +57,7 @@ export default defineComponent({
() => recipeImageUrl.value,
() => {
hideImage.value = false;
}
},
);
return {
@@ -64,6 +66,6 @@ export default defineComponent({
hideImage,
imageHeight,
};
}
},
});
</script>

View File

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

View File

@@ -1,11 +1,14 @@
<!-- eslint-disable vue/no-mutating-props -->
<template>
<div>
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<draggable
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
{{ $t("recipe.ingredients") }}
</h2>
<VueDraggable
v-if="recipe.recipeIngredient.length > 0"
v-model="recipe.recipeIngredient"
handle=".handle"
delay="250"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
@@ -16,7 +19,9 @@
@start="drag = true"
@end="drag = false"
>
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
<TransitionGroup
type="transition"
>
<RecipeIngredientEditor
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
@@ -25,21 +30,29 @@
:disable-amount="recipe.settings.disableAmount"
@delete="recipe.recipeIngredient.splice(index, 1)"
@insert-above="insertNewIngredient(index)"
@insert-below="insertNewIngredient(index+1)"
@insert-below="insertNewIngredient(index + 1)"
/>
</TransitionGroup>
</draggable>
<v-skeleton-loader v-else boilerplate elevation="2" type="list-item"> </v-skeleton-loader>
</VueDraggable>
<v-skeleton-loader
v-else
boilerplate
elevation="2"
type="list-item"
/>
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
<v-tooltip top color="accent">
<template #activator="{ on, attrs }">
<span v-on="on">
<v-tooltip
top
color="accent"
>
<template #activator="{ props }">
<span>
<BaseButton
class="mb-1"
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
color="accent"
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
v-bind="attrs"
v-bind="props"
>
<template #icon>
{{ $globals.icons.foods }}
@@ -50,124 +63,106 @@
</template>
<span>{{ parserToolTip }}</span>
</v-tooltip>
<RecipeDialogBulkAdd class="mx-1 mb-1" @bulk-data="addIngredient" />
<BaseButton class="mb-1" @click="addIngredient" > {{ $t("general.add") }} </BaseButton>
<RecipeDialogBulkAdd
class="mx-1 mb-1"
@bulk-data="addIngredient"
/>
<BaseButton
class="mb-1"
@click="addIngredient"
>
{{ $t("general.add") }}
</BaseButton>
</div>
</div>
</template>
<script lang="ts">
import draggable from "vuedraggable";
import { computed, defineComponent, ref, useContext, useRoute } from "@nuxtjs/composition-api";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/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 { $auth, i18n } = useContext();
const drag = ref(false);
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const i18n = useI18n();
const $auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
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;
}
}
}
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
return false;
});
const parserToolTip = computed(() => {
if (props.recipe.settings.disableAmount) {
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
} else if (hasFoodOrUnit.value) {
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
}
return i18n.t("recipe.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,
});
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;
}
}
function insertNewIngredient(dest: number) {
props.recipe.recipeIngredient.splice(dest, 0, {
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,
groupSlug,
addIngredient,
parserToolTip,
hasFoodOrUnit,
imageKey,
drag,
insertNewIngredient,
};
},
}
return false;
});
const parserToolTip = computed(() => {
if (recipe.value.settings.disableAmount) {
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
}
else if (hasFoodOrUnit.value) {
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
}
return i18n.t("recipe.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
recipe.value.recipeIngredient.push(...newIngredients);
}
}
else {
recipe.value.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,
});
}
}
function insertNewIngredient(dest: number) {
recipe.value.recipeIngredient.splice(dest, 0, {
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,
});
}
</script>

View File

@@ -7,38 +7,47 @@
:is-cook-mode="isCookMode"
/>
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
<h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2>
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
<v-checkbox
v-model="recipeTools[index].onHand"
hide-details
class="pt-0 my-auto py-auto"
color="secondary"
@change="updateTool(index)"
<h2 class="mt-4 text-h5 font-weight-medium opacity-80">
{{ $t('tool.required-tools') }}
</h2>
<v-list density="compact">
<v-list-item
v-for="(tool, index) in recipe.tools"
:key="index"
density="compact"
>
</v-checkbox>
<v-list-item-content>
{{ tool.name }}
</v-list-item-content>
</v-list-item>
<template #prepend>
<v-checkbox
v-model="recipeTools[index].onHand"
hide-details
class="pt-0 my-auto py-auto"
color="secondary"
density="compact"
@change="updateTool(index)"
/>
</template>
<v-list-item-title>
{{ tool.name }}
</v-list-item-title>
</v-list-item>
</v-list>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { useToolStore } from "~/composables/store";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe, RecipeTool } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe, RecipeTool } from "~/lib/api/types/recipe";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineComponent({
export default defineNuxtComponent({
components: {
RecipeIngredients,
},
@@ -54,7 +63,7 @@ export default defineComponent({
isCookMode: {
type: Boolean,
default: false,
}
},
},
setup(props) {
const { isOwnGroup } = useLoggedInState();
@@ -65,14 +74,15 @@ export default defineComponent({
const recipeTools = computed(() => {
if (!(user.householdSlug && toolStore)) {
return props.recipe.tools.map((tool) => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
} else {
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
}
else {
return props.recipe.tools.map((tool) => {
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
return { ...tool, onHand } as RecipeToolWithOnHand;
});
}
})
});
function updateTool(index: number) {
if (user.id && user.householdSlug && toolStore) {
@@ -80,15 +90,18 @@ export default defineComponent({
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
if (!tool.householdsWithTool) {
tool.householdsWithTool = [user.householdSlug];
} else {
}
else {
tool.householdsWithTool.push(user.householdSlug);
}
} else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
tool.householdsWithTool = tool.householdsWithTool.filter((household) => household !== user.householdSlug);
}
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
}
toolStore.actions.updateOne(tool);
} else {
}
else {
console.log("no user, skipping server update");
}
}

View File

@@ -1,21 +1,34 @@
<template>
<section @keyup.ctrl.90="undoMerge">
<section @keyup.ctrl.z="undoMerge">
<!-- Ingredient Link Editor -->
<v-dialog v-if="dialog" v-model="dialog" width="600">
<v-dialog
v-if="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>
<v-sheet
color="primary"
class="mt-n1 mb-3 pa-3 d-flex align-center"
style="border-radius: 6px; width: 100%;"
>
<v-icon
size="large"
start
>
{{ $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-toolbar-title class="headline">
{{ $t("recipe.ingredient-linker") }}
</v-toolbar-title>
<v-spacer />
</v-sheet>
<v-card-text class="pt-4">
<p>
{{ activeText }}
</p>
<v-divider class="mb-4"></v-divider>
<v-divider class="mb-4" />
<v-checkbox
v-for="ing in unusedIngredients"
:key="ing.referenceId"
@@ -29,7 +42,9 @@
</v-checkbox>
<template v-if="usedIngredients.length > 0">
<h4 class="py-3 ml-1">{{ $t("recipe.linked-to-other-step") }}</h4>
<h4 class="py-3 ml-1">
{{ $t("recipe.linked-to-other-step") }}
</h4>
<v-checkbox
v-for="ing in usedIngredients"
:key="ing.referenceId"
@@ -44,19 +59,38 @@
</template>
</v-card-text>
<v-divider></v-divider>
<v-divider />
<v-card-actions>
<BaseButton cancel @click="dialog = false"> </BaseButton>
<v-spacer></v-spacer>
<BaseButton
cancel
@click="dialog = false"
/>
<v-spacer />
<div class="d-flex flex-wrap justify-end">
<BaseButton class="my-1" color="info" @click="autoSetReferences">
<template #icon> {{ $globals.icons.robot }}</template>
<BaseButton
class="my-1"
color="info"
@click="autoSetReferences"
>
<template #icon>
{{ $globals.icons.robot }}
</template>
{{ $t("recipe.auto") }}
</BaseButton>
<BaseButton class="ml-2 my-1" save @click="setIngredientIds"> </BaseButton>
<BaseButton v-if="availableNextStep" class="ml-2 my-1" @click="saveAndOpenNextLinkIngredients">
<template #icon> {{ $globals.icons.forward }}</template>
<BaseButton
class="ml-2 my-1"
save
@click="setIngredientIds"
/>
<BaseButton
v-if="availableNextStep"
class="ml-2 my-1"
@click="saveAndOpenNextLinkIngredients"
>
<template #icon>
{{ $globals.icons.forward }}
</template>
{{ $t("recipe.nextStep") }}
</BaseButton>
</div>
@@ -65,169 +99,200 @@
</v-dialog>
<div class="d-flex justify-space-between justify-start">
<h2 v-if="!isCookMode" class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
<BaseButton v-if="!isEditForm && !isCookMode" minor cancel color="primary" @click="toggleCookMode()">
<h2
v-if="!isCookMode"
class="mt-1 text-h5 font-weight-medium opacity-80"
>
{{ $t("recipe.instructions") }}
</h2>
<BaseButton
v-if="!isEditForm && !isCookMode"
minor
cancel
color="primary"
@click="toggleCookMode()"
>
<template #icon>
{{ $globals.icons.primary }}
</template>
{{ $t("recipe.cook-mode") }}
</BaseButton>
</div>
<draggable
<VueDraggable
v-model="instructionList"
:disabled="!isEditForm"
:value="value"
handle=".handle"
delay="250"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
group: 'recipe-instructions',
ghostClass: 'ghost',
}"
@input="updateIndex"
@start="drag = true"
@end="drag = false"
@end="onDragEnd"
>
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
<div v-for="(step, index) in value" :key="step.id" class="list-group-item">
<v-app-bar
<TransitionGroup
type="transition"
>
<div
v-for="(step, index) in instructionList"
:key="step.id!"
class="list-group-item"
>
<v-sheet
v-if="step.id && showTitleEditor[step.id]"
class="primary mt-6"
style="cursor: pointer"
dark
dense
rounded
color="primary"
class="mt-6 mb-2 d-flex align-center"
:class="isEditForm ? 'pa-2' : 'pa-3'"
style="border-radius: 6px; cursor: pointer; width: 100%;"
@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 }">
<template v-if="isEditForm">
<v-text-field
v-model="step.title"
class="pa-0"
density="compact"
variant="solo"
flat
:placeholder="$t('recipe.section-title')"
bg-color="primary"
hide-details
/>
</template>
<template v-else>
<v-toolbar-title class="section-title-text">
{{ step.title }}
</v-toolbar-title>
</template>
</v-sheet>
<v-hover v-slot="{ isHovering }">
<v-card
class="my-3"
:class="[{ 'on-hover': hover }, isChecked(index)]"
:elevation="hover ? 12 : 2"
:class="[{ 'on-hover': isHovering }, isChecked(index)]"
:elevation="isHovering ? 12 : 2"
:ripple="false"
@click="toggleDisabled(index)"
>
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
<v-text-field
v-if="isEditForm"
v-model="step.summary"
class="headline handle"
hide-details
dense
solo
flat
:placeholder="$t('recipe.step-index', { step: index + 1 })"
>
<template #prepend>
<v-icon size="26">{{ $globals.icons.arrowUpDown }}</v-icon>
<div class="d-flex align-center">
<v-text-field
v-if="isEditForm"
v-model="step.summary"
class="headline handle"
hide-details
density="compact"
variant="solo"
flat
:placeholder="$t('recipe.step-index', { step: index + 1 })"
>
<template #prepend>
<v-icon size="26">
{{ $globals.icons.arrowUpDown }}
</v-icon>
</template>
</v-text-field>
<span v-else>
{{ step.summary ? step.summary : $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: $t('general.delete'),
event: 'delete',
},
{
icon: $globals.icons.dotsVertical,
text: '',
event: 'open',
children: [
{
text: $t('recipe.toggle-section'),
event: 'toggle-section',
},
{
text: $t('recipe.link-ingredients'),
event: 'link-ingredients',
},
{
text: $t('recipe.upload-image'),
event: 'upload-image',
},
{
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
text: previewStates[index] ? $t('recipe.edit-markdown') : $t('markdown-editor.preview-markdown-button-label'),
event: 'preview-step',
divider: true,
},
{
text: $t('recipe.merge-above'),
event: 'merge-above',
},
{
text: $t('recipe.move-to-top'),
event: 'move-to-top',
},
{
text: $t('recipe.move-to-bottom'),
event: 'move-to-bottom',
},
{
text: $t('recipe.insert-above'),
event: 'insert-above',
},
{
text: $t('recipe.insert-below'),
event: 'insert-below',
},
],
},
]"
@merge-above="mergeAbove(index - 1, index)"
@move-to-top="moveTo('top', index)"
@move-to-bottom="moveTo('bottom', index)"
@insert-above="insert(index)"
@insert-below="insert(index + 1)"
@toggle-section="toggleShowTitle(step.id!)"
@link-ingredients="openDialog(index, step.text, step.ingredientReferences)"
@preview-step="togglePreviewState(index)"
@upload-image="openImageUpload(index)"
@delete="instructionList.splice(index, 1)"
/>
</div>
</template>
</v-text-field>
<span v-else>
{{ step.summary ? step.summary : $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: $tc('recipe.toggle-section'),
event: 'toggle-section',
},
{
text: $tc('recipe.link-ingredients'),
event: 'link-ingredients',
},
{
text: $tc('recipe.upload-image'),
event: 'upload-image'
},
{
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
text: previewStates[index] ? $tc('recipe.edit-markdown') : $tc('markdown-editor.preview-markdown-button-label'),
event: 'preview-step',
divider: true,
},
{
text: $tc('recipe.merge-above'),
event: 'merge-above',
},
{
text: $tc('recipe.move-to-top'),
event: 'move-to-top',
},
{
text: $tc('recipe.move-to-bottom'),
event: 'move-to-bottom',
},
{
text: $tc('recipe.insert-above'),
event: 'insert-above'
},
{
text: $tc('recipe.insert-below'),
event: 'insert-below'
},
],
},
]"
@merge-above="mergeAbove(index - 1, index)"
@move-to-top="moveTo('top', index)"
@move-to-bottom="moveTo('bottom', index)"
@insert-above="insert(index)"
@insert-below="insert(index+1)"
@toggle-section="toggleShowTitle(step.id)"
@link-ingredients="openDialog(index, step.text, step.ingredientReferences)"
@preview-step="togglePreviewState(index)"
@upload-image="openImageUpload(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-fade-transition>
<v-icon
v-show="isChecked(index)"
size="24"
class="ml-auto"
color="success"
>
{{ $globals.icons.checkboxMarkedCircle }}
</v-icon>
</v-fade-transition>
</div>
</v-card-title>
<v-progress-linear v-if="isEditForm && loadingStates[index]" :active="true" :indeterminate="true" />
<v-progress-linear
v-if="isEditForm && loadingStates[index]"
:active="true"
:indeterminate="true"
/>
<!-- Content -->
<DropZone @drop="(f) => handleImageDrop(index, f)">
<v-card-text
v-if="isEditForm"
@click="$emit('click-instruction-field', `${index}.text`)"
v-if="isEditForm"
@click="$emit('click-instruction-field', `${index}.text`)"
>
<MarkdownEditor
v-model="value[index]['text']"
v-model="instructionList[index]['text']"
v-model:preview="previewStates[index]"
class="mb-2"
:preview.sync="previewStates[index]"
:display-preview="false"
:textarea="{
hint: $t('recipe.attach-images-hint'),
@@ -236,14 +301,16 @@
/>
<RecipeIngredientHtml
v-for="ing in step.ingredientReferences"
:key="ing.referenceId"
:markup="getIngredientByRefId(ing.referenceId)"
:key="ing.referenceId!"
:markup="getIngredientByRefId(ing.referenceId!)"
/>
</v-card-text>
</DropZone>
<v-expand-transition>
<div v-show="!isChecked(index) && !isEditForm" class="m-0 p-0">
<div
v-if="!isChecked(index) && !isEditForm"
class="m-0 p-0"
>
<v-card-text class="markdown">
<v-row>
<v-col
@@ -254,7 +321,7 @@
<div class="ml-n4">
<RecipeIngredients
:value="recipe.recipeIngredient.filter((ing) => {
if(!step.ingredientReferences) return false
if (!step.ingredientReferences) return false
return step.ingredientReferences.map((ref) => ref.referenceId).includes(ing.referenceId || '')
})"
:scale="scale"
@@ -263,9 +330,15 @@
/>
</div>
</v-col>
<v-divider v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.breakpoint.smAndUp" vertical ></v-divider>
<v-divider
v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.display.smAndUp"
vertical
/>
<v-col>
<SafeMarkdown class="markdown" :source="step.text" />
<SafeMarkdown
class="markdown"
:source="step.text"
/>
</v-col>
</v-row>
</v-card-text>
@@ -275,34 +348,27 @@
</v-hover>
</div>
</TransitionGroup>
</draggable>
<v-divider v-if="!isCookMode" class="mt-10 d-flex d-md-none"/>
</VueDraggable>
<v-divider
v-if="!isCookMode"
class="mt-10 d-flex d-md-none"
/>
</section>
</template>
<script lang="ts">
import draggable from "vuedraggable";
import {
ref,
toRefs,
reactive,
defineComponent,
watch,
onMounted,
useContext,
computed,
nextTick,
} from "@nuxtjs/composition-api";
import { VueDraggable } from "vue-draggable-plus";
import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue";
import { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
import { parseIngredientText } from "~/composables/recipes";
import { uuid4, detectServerBaseUrl } from "~/composables/use-utils";
import { uuid4 } from "~/composables/use-utils";
import { useUserApi, useStaticRoutes } from "~/composables/api";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { useExtractIngredientReferences } from "~/composables/recipe-page/use-extract-ingredient-references";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import DropZone from "~/components/global/DropZone.vue";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
interface MergerHistory {
target: number;
source: number;
@@ -310,15 +376,15 @@ interface MergerHistory {
sourceText: string;
}
export default defineComponent({
export default defineNuxtComponent({
components: {
draggable,
VueDraggable,
RecipeIngredientHtml,
DropZone,
RecipeIngredients
RecipeIngredients,
},
props: {
value: {
modelValue: {
type: Array as () => RecipeStep[],
required: false,
default: () => [],
@@ -336,10 +402,11 @@ export default defineComponent({
default: 1,
},
},
emits: ["update:modelValue", "click-instruction-field", "update:assets"],
setup(props, context) {
const { i18n, req } = useContext();
const BASE_URL = detectServerBaseUrl(req);
const i18n = useI18n();
const BASE_URL = useRequestURL().origin;
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
@@ -374,12 +441,12 @@ export default defineComponent({
return !(title === null || title === "" || title === undefined);
}
watch(props.value, (v) => {
watch(props.modelValue, (v) => {
state.disabledSteps = [];
v.forEach((element: RecipeStep) => {
if (element.id !== undefined) {
showTitleEditor.value[element.id] = hasSectionTitle(element.title);
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
}
});
});
@@ -388,9 +455,9 @@ export default defineComponent({
// Eliminate state with an eager call to watcher?
onMounted(() => {
props.value.forEach((element: RecipeStep) => {
props.modelValue.forEach((element: RecipeStep) => {
if (element.id !== undefined) {
showTitleEditor.value[element.id] = hasSectionTitle(element.title);
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
}
// showCookMode.value = false;
@@ -411,7 +478,8 @@ export default defineComponent({
if (index !== -1) {
state.disabledSteps.splice(index, 1);
}
} else {
}
else {
state.disabledSteps.push(stepIndex);
}
}
@@ -433,8 +501,19 @@ export default defineComponent({
showTitleEditor.value = temp;
}
function updateIndex(data: RecipeStep) {
context.emit("input", data);
const instructionList = ref<RecipeStep[]>([...props.modelValue]);
watch(
() => props.modelValue,
(newVal) => {
instructionList.value = [...newVal];
},
{ deep: true },
);
function onDragEnd() {
context.emit("update:modelValue", [...instructionList.value]);
drag.value = false;
}
// ===============================================================
@@ -445,21 +524,21 @@ export default defineComponent({
function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
if (!refs) {
props.value[idx].ingredientReferences = [];
refs = props.value[idx].ingredientReferences as IngredientReferences[];
instructionList.value[idx].ingredientReferences = [];
refs = props.modelValue[idx].ingredientReferences as IngredientReferences[];
}
setUsedIngredients();
activeText.value = text;
activeIndex.value = idx;
state.dialog = true;
activeRefs.value = refs.map((ref) => ref.referenceId ?? "");
activeRefs.value = refs.map(ref => ref.referenceId ?? "");
}
const availableNextStep = computed(() => activeIndex.value < props.value.length - 1);
const availableNextStep = computed(() => activeIndex.value < props.modelValue.length - 1);
function setIngredientIds() {
const instruction = props.value[activeIndex.value];
const instruction = props.modelValue[activeIndex.value];
instruction.ingredientReferences = activeRefs.value.map((ref) => {
return {
referenceId: ref,
@@ -468,7 +547,7 @@ export default defineComponent({
// Update the visibility of the cook mode button
showCookMode.value = false;
props.value.forEach((element) => {
props.modelValue.forEach((element) => {
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
showCookMode.value = true;
}
@@ -479,24 +558,23 @@ export default defineComponent({
function saveAndOpenNextLinkIngredients() {
const currentStepIndex = activeIndex.value;
if(!availableNextStep.value) {
if (!availableNextStep.value) {
return; // no next step, the button calling this function should not be shown
}
setIngredientIds();
const nextStep = props.value[currentStepIndex + 1];
const nextStep = props.modelValue[currentStepIndex + 1];
// close dialog before opening to reset the scroll position
nextTick(() => openDialog(currentStepIndex + 1, nextStep.text, nextStep.ingredientReferences));
}
function setUsedIngredients() {
const usedRefs: { [key: string]: boolean } = {};
props.value.forEach((element) => {
props.modelValue.forEach((element) => {
element.ingredientReferences?.forEach((ref) => {
if (ref.referenceId !== undefined) {
usedRefs[ref.referenceId] = true;
usedRefs[ref.referenceId!] = true;
}
});
});
@@ -515,7 +593,7 @@ export default defineComponent({
props.recipe.recipeIngredient,
activeRefs.value,
activeText.value,
props.recipe.settings.disableAmount
props.recipe.settings.disableAmount,
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
}
@@ -535,10 +613,8 @@ export default defineComponent({
return "";
}
const ing = ingredientLookup.value[refId] ?? "";
if (ing === "") {
return "";
}
const ing = ingredientLookup.value[refId];
if (!ing) return "";
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
}
@@ -554,12 +630,12 @@ export default defineComponent({
mergeHistory.value.push({
target,
source,
targetText: props.value[target].text,
sourceText: props.value[source].text,
targetText: props.modelValue[target].text,
sourceText: props.modelValue[source].text,
});
props.value[target].text += " " + props.value[source].text;
props.value.splice(source, 1);
instructionList.value[target].text += " " + props.modelValue[source].text;
instructionList.value.splice(source, 1);
}
function undoMerge(event: KeyboardEvent) {
@@ -573,8 +649,8 @@ export default defineComponent({
return;
}
props.value[lastMerge.target].text = lastMerge.targetText;
props.value.splice(lastMerge.source, 0, {
instructionList.value[lastMerge.target].text = lastMerge.targetText;
instructionList.value.splice(lastMerge.source, 0, {
id: uuid4(),
title: "",
text: lastMerge.sourceText,
@@ -585,14 +661,15 @@ export default defineComponent({
function moveTo(dest: string, source: number) {
if (dest === "top") {
props.value.unshift(props.value.splice(source, 1)[0]);
} else {
props.value.push(props.value.splice(source, 1)[0]);
instructionList.value.unshift(instructionList.value.splice(source, 1)[0]);
}
else {
instructionList.value.push(instructionList.value.splice(source, 1)[0]);
}
}
function insert(dest: number) {
props.value.splice(dest, 0, { id: uuid4(), text: "", title: "", ingredientReferences: [] });
instructionList.value.splice(dest, 0, { id: uuid4(), text: "", title: "", ingredientReferences: [] });
}
const previewStates = ref<boolean[]>([]);
@@ -606,19 +683,21 @@ export default defineComponent({
function toggleCollapseSection(index: number) {
const sectionSteps: number[] = [];
for (let i = index; i < props.value.length; i++) {
if (!(i === index) && hasSectionTitle(props.value[i].title)) {
for (let i = index; i < instructionList.value.length; i++) {
if (!(i === index) && hasSectionTitle(instructionList.value[i].title!)) {
break;
} else {
}
else {
sectionSteps.push(i);
}
}
const allCollapsed = sectionSteps.every((idx) => state.disabledSteps.includes(idx));
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.filter(idx => !sectionSteps.includes(idx));
}
else {
state.disabledSteps = [...state.disabledSteps, ...sectionSteps];
}
}
@@ -674,7 +753,7 @@ export default defineComponent({
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;
instructionList.value[index].text += text;
}
function openImageUpload(index: number) {
@@ -690,6 +769,8 @@ export default defineComponent({
input.click();
}
const breakpoint = useDisplay();
return {
// Image Uploader
toggleDragMode,
@@ -699,6 +780,7 @@ export default defineComponent({
loadingStates,
// Rest
onDragEnd,
drag,
togglePreviewState,
toggleCollapseSection,
@@ -719,7 +801,7 @@ export default defineComponent({
toggleDisabled,
isChecked,
toggleShowTitle,
updateIndex,
instructionList,
autoSetReferences,
parseIngredientText,
toggleCookMode,
@@ -727,6 +809,7 @@ export default defineComponent({
isCookMode,
isEditForm,
insert,
breakpoint,
};
},
});
@@ -738,28 +821,32 @@ export default defineComponent({
}
/** Select all li under .markdown class */
.markdown >>> ul > li {
.markdown :deep(ul > li) {
display: list-item;
list-style-type: disc !important;
}
/** Select all li under .markdown class */
.markdown >>> ol > li {
.markdown :deep(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 i {
cursor: pointer;
}
@@ -780,4 +867,8 @@ export default defineComponent({
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
.v-text-field >>> input {
font-size: 1.5rem;
}
</style>

View File

@@ -1,7 +1,10 @@
<template>
<div>
<!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || isEditForm" :class="{'mt-10': !isEditForm}">
<v-card
v-if="recipe.recipeCategory.length > 0 || isEditForm"
:class="{ 'mt-10': !isEditForm }"
>
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
@@ -14,12 +17,19 @@
:show-add="true"
selector-type="categories"
/>
<RecipeChips v-else :items="recipe.recipeCategory" v-on="$listeners" />
<RecipeChips
v-else
:items="recipe.recipeCategory"
v-bind="$attrs"
/>
</v-card-text>
</v-card>
<!-- Recipe Tags -->
<v-card v-if="recipe.tags.length > 0 || isEditForm" class="mt-4">
<v-card
v-if="recipe.tags.length > 0 || isEditForm"
class="mt-4"
>
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
@@ -32,20 +42,39 @@
:show-add="true"
selector-type="tags"
/>
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" v-on="$listeners" />
<RecipeChips
v-else
:items="recipe.tags"
url-prefix="tags"
v-bind="$attrs"
/>
</v-card-text>
</v-card>
<!-- Recipe Tools Edit -->
<v-card v-if="isEditForm" class="mt-2">
<v-card-title class="py-2"> {{ $t('tool.required-tools') }} </v-card-title>
<v-card
v-if="isEditForm"
class="mt-2"
>
<v-card-title class="py-2">
{{ $t('tool.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-on="$listeners" />
<RecipeOrganizerSelector
v-model="recipe.tools"
selector-type="tools"
v-bind="$attrs"
/>
</v-card-text>
</v-card>
<RecipeNutrition v-if="recipe.settings.showNutrition" v-model="recipe.nutrition" class="mt-4" :edit="isEditForm" />
<RecipeNutrition
v-if="recipe.settings.showNutrition"
v-model="recipe.nutrition"
class="mt-4"
:edit="isEditForm"
/>
<RecipeAssets
v-if="recipe.settings.showAssets"
v-model="recipe.assets"
@@ -56,38 +85,15 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
<script lang="ts" setup>
import { usePageState } from "~/composables/recipe-page/shared-state";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/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,
};
},
});
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const { isEditForm } = usePageState(recipe.value.slug);
</script>

View File

@@ -1,28 +1,21 @@
<template>
<div class="d-flex justify-space-between align-center pt-2 pb-3">
<v-tooltip v-if="!isEditMode" small top color="secondary darken-1">
<template #activator="{ on, attrs }">
<RecipeScaleEditButton
v-model.number="scaleValue"
v-bind="attrs"
:recipe-servings="recipeServings"
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
v-on="on"
/>
</template>
<span> {{ $t("recipe.edit-scale") }} </span>
</v-tooltip>
<RecipeScaleEditButton
v-if="!isEditMode"
v-model.number="scaleValue"
:recipe-servings="recipeServings"
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
/>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
import { usePageState } from "~/composables/recipe-page/shared-state";
export default defineComponent({
export default defineNuxtComponent({
components: {
RecipeScaleEditButton,
},
@@ -36,6 +29,7 @@ export default defineComponent({
default: 1,
},
},
emits: ["update:scale"],
setup(props, { emit }) {
const { isEditMode } = usePageState(props.recipe.slug);