chore: Nuxt 4 upgrade (#7426)

This commit is contained in:
Kuchenpirat
2026-04-08 17:25:41 +02:00
committed by GitHub
parent 70a251a331
commit d3e41582ae
561 changed files with 1840 additions and 2750 deletions

View File

@@ -0,0 +1,118 @@
<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" />
<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
density="compact"
single-line
variant="outlined"
auto-grow
rows="2"
:placeholder="$t('recipe.join-the-conversation')"
/>
</div>
<div class="ml-auto mt-1">
<BaseButton
size="small"
:disabled="!comment"
@click="submitComment"
>
<template #icon>
{{ $globals.icons.check }}
</template>
{{ $t("general.submit") }}
</BaseButton>
</div>
</div>
<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="">
{{ 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 == recipeComment.user.id || user.admin"
color="error"
variant="text"
size="x-small"
@click="deleteComment(recipeComment.id)"
>
{{ $t("general.delete") }}
</v-btn>
</v-card-actions>
</v-card>
</div>
</div>
</template>
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import type { Recipe } from "~/lib/api/types/recipe";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { usePageUser } from "~/composables/recipe-page/shared-state";
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const api = useUserApi();
const { user } = usePageUser();
const comment = ref("");
async function submitComment() {
const { data } = await api.recipes.comments.createOne({
recipeId: recipe.value.id,
text: comment.value,
});
if (data) {
recipe.value.comments.push(data);
}
comment.value = "";
}
async function deleteComment(id: string) {
const { response } = await api.recipes.comments.deleteOne(id);
if (response?.status === 200) {
recipe.value.comments = recipe.value.comments.filter(comment => comment.id !== id);
}
}
</script>

View File

@@ -0,0 +1,88 @@
<template>
<div class="d-flex justify-start align-top flex-wrap">
<RecipeImageUploadBtn
class="my-2"
:slug="recipe.slug"
@upload="uploadImage"
@refresh="imageKey++"
@delete="deleteImage"
/>
<RecipeSettingsMenu
v-model="recipe.settings"
class="my-2 mx-1"
:is-owner="recipe.userId == user.id"
@upload="uploadImage"
/>
<v-spacer />
<v-select
v-model="recipe.userId"
class="my-2"
max-width="300"
:items="allUsers"
:item-props="itemsProps"
:label="$t('general.owner')"
:disabled="!canEditOwner"
variant="outlined"
density="compact"
>
<template #prepend>
<UserAvatar
:user-id="recipe.userId"
:tooltip="false"
/>
</template>
</v-select>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
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";
import { useUserStore } from "~/composables/store/use-user-store";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { useHouseholdStore } from "~/composables/store";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const { user } = usePageUser();
const api = useUserApi();
const { imageKey } = usePageState(recipe.value.slug);
const canEditOwner = computed(() => {
return user.id === recipe.value.userId || user.admin;
});
const { store: allUsers } = useUserStore();
const { store: households } = useHouseholdStore();
function itemsProps(item: any) {
const owner = allUsers.value.find(u => u.id === item.id);
return {
value: item.id,
title: item.fullName,
subtitle: owner ? households.value.find(h => h.id === owner.householdId)?.name || "" : "",
};
}
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++;
}
async function deleteImage() {
// The image is already deleted on the backend, just need to update the UI
recipe.value.image = "";
imageKey.value++;
}
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div>
<v-card-actions class="justify-end">
<v-text-field
v-if="isEditForm"
v-model="recipe.orgURL"
class="mt-10"
variant="underlined"
:label="$t('recipe.original-url')"
/>
<v-btn
v-else-if="recipe.orgURL && !isCookMode"
:hover="false"
:ripple="false"
variant="flat"
:href="recipe.orgURL"
color="secondary-darken-1"
target="_blank"
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 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-col style="max-width: 400px;">
<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-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 ml-2 mt-n3">
<div>
<v-text-field
v-model="apiNewKey"
min-width="200px"
:label="$t('recipe.message-key')"
variant="underlined"
/>
</div>
<BaseButton
create
size="small"
class="ml-5"
@click="createApiExtra"
/>
</v-card-actions>
</v-card>
</AdvancedOnly>
</div>
</template>
<script setup lang="ts">
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";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const { isEditForm, isCookMode } = usePageState(recipe.value.slug);
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 | 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

@@ -0,0 +1,82 @@
<template>
<div>
<RecipePageInfoCard
:recipe="recipe"
:recipe-scale="recipeScale"
:landscape="landscape"
/>
<v-divider />
<RecipeActionMenu
:recipe="recipe"
:slug="recipe.slug"
:recipe-scale="recipeScale"
:can-edit="canEditRecipe"
:name="recipe.name"
:logged-in="isOwnGroup"
:open="isEditMode"
:recipe-id="recipe.id"
class="ml-auto mt-n7 pb-4"
@close="$emit('close')"
@json="toggleEditMode()"
@edit="setMode(PageMode.EDIT)"
@save="$emit('save')"
@delete="$emit('delete')"
@print="printRecipe"
/>
</div>
</template>
<script setup lang="ts">
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 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 } from "~/composables/recipe-page/shared-state";
interface Props {
recipe: NoUndefinedField<Recipe>;
recipeScale?: number;
landscape?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
recipeScale: 1,
landscape: false,
});
defineEmits(["save", "delete", "print", "close"]);
const { recipeImage } = useStaticRoutes();
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const { isOwnGroup } = useLoggedInState();
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}
const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
function printRecipe() {
window.print();
}
const hideImage = ref(false);
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
watch(
() => recipeImageUrl.value,
() => {
hideImage.value = false;
},
);
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div>
<div class="d-flex justify-end flex-wrap align-stretch">
<RecipePageInfoCardImage
v-if="landscape"
:recipe="recipe"
/>
<v-card
:width="landscape ? '100%' : '50%'"
flat
class="d-flex flex-column justify-center align-center"
>
<v-card-text class="w-100">
<div class="d-flex flex-column align-center">
<v-card-title class="text-h5 font-weight-regular pa-0 text-wrap text-center opacity-80">
{{ recipe.name }}
</v-card-title>
<RecipeRating
:key="recipe.slug"
:model-value="recipe.rating"
:recipe-id="recipe.id"
:slug="recipe.slug"
/>
</div>
<v-divider class="my-2" />
<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"
>
<RecipeYield
:yield-quantity="recipe.recipeYieldQuantity"
:yield-text="recipe.recipeYield"
:scale="recipeScale"
class="mb-4"
/>
</v-col>
</v-row>
<v-row no-gutters>
<v-col
cols="12"
class="d-flex flex-wrap justify-center"
>
<RecipeLastMade
v-if="isOwnGroup"
:recipe="recipe"
class="mb-4"
/>
</v-col>
</v-row>
</div>
<div v-if="recipe.prepTime || recipe.totalTime || recipe.performTime" class="mx-6">
<RecipeTimeCard
container-class="d-flex flex-wrap justify-center"
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
class="mb-4"
/>
</div>
</v-container>
</v-card-text>
</v-card>
<RecipePageInfoCardImage
v-if="!landscape"
:recipe="recipe"
max-width="50%"
class="my-auto"
/>
</div>
</div>
</template>
<script setup lang="ts">
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 type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
interface Props {
recipe: NoUndefinedField<Recipe>;
recipeScale?: number;
landscape: boolean;
}
withDefaults(defineProps<Props>(), {
recipeScale: 1,
});
const { isOwnGroup } = useLoggedInState();
</script>

View File

@@ -0,0 +1,60 @@
<template>
<v-img
:key="imageKey"
:max-width="maxWidth"
min-height="50"
cover
width="100%"
:height="hideImage ? undefined : imageHeight"
:src="recipeImageUrl"
class="d-print-none"
@error="hideImage = true"
/>
</template>
<script setup lang="ts">
import { useStaticRoutes, useUserApi } from "~/composables/api";
import type { HouseholdSummary } from "~/lib/api/types/household";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
interface Props {
recipe: NoUndefinedField<Recipe>;
maxWidth?: string;
}
const props = withDefaults(defineProps<Props>(), {
maxWidth: undefined,
});
const display = useDisplay();
const { recipeImage, recipeSmallImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}
const hideImage = ref(false);
const imageHeight = computed(() => {
return display.xs.value ? "200" : "400";
});
const recipeImageUrl = computed(() => {
return display.smAndDown.value
? recipeSmallImage(props.recipe.id, props.recipe.image, imageKey.value)
: recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
watch(
() => recipeImageUrl.value,
() => {
hideImage.value = false;
},
);
</script>

View File

@@ -0,0 +1,88 @@
<template>
<div>
<v-text-field
v-model="recipe.name"
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-number-input
:model-value="recipe.recipeServings"
:min="0"
:precision="null"
density="compact"
:label="$t('recipe.servings')"
variant="underlined"
control-variant="hidden"
@update:model-value="recipe.recipeServings = $event"
/>
</v-col>
<v-col cols="3">
<v-number-input
:model-value="recipe.recipeYieldQuantity"
:min="0"
:precision="null"
density="compact"
:label="$t('recipe.yield')"
variant="underlined"
control-variant="hidden"
@update:model-value="recipe.recipeYieldQuantity = $event"
/>
</v-col>
<v-col cols="6">
<v-text-field
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')"
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')"
density="compact"
variant="underlined"
/>
</div>
</template>
<script setup lang="ts">
import { validators } from "~/composables/use-validators";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
</script>

View File

@@ -0,0 +1,277 @@
<template>
<div>
<div class="mb-4">
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
{{ $t("recipe.ingredients") }}
</h2>
<BannerWarning v-if="!hasFoodOrUnit">
{{ $t("recipe.ingredients-not-parsed-description", { parse: $t('recipe.parse') }) }}
</BannerWarning>
</div>
<VueDraggable
v-if="recipe.recipeIngredient.length > 0"
v-model="recipe.recipeIngredient"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
group: 'recipe-ingredients',
disabled: false,
ghostClass: 'ghost',
}"
@start="drag = true"
@end="drag = false"
>
<TransitionGroup
type="transition"
>
<RecipeIngredientEditor
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
:is-recipe="ingredientIsRecipe(ingredient)"
enable-drag-handle
enable-context-menu
class="list-group-item"
@delete="recipe.recipeIngredient.splice(index, 1)"
@insert-above="insertNewIngredient(index)"
@insert-below="insertNewIngredient(index + 1)"
/>
</TransitionGroup>
</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
location="top"
color="accent"
>
<template #activator="{ props }">
<span>
<BaseButton
class="mb-1"
:disabled="hasFoodOrUnit"
color="accent"
v-bind="props"
@click="toggleIsParsing(true)"
>
<template #icon>
{{ $globals.icons.foods }}
</template>
{{ $t('recipe.parse') }}
</BaseButton>
</span>
</template>
<span>{{ parserToolTip }}</span>
</v-tooltip>
<RecipeDialogBulkAdd
ref="domBulkAddDialog"
class="mx-1 mb-1"
style="display: none"
@bulk-data="addIngredient"
/>
<div class="d-inline-flex">
<!-- Main button: Add Food -->
<v-btn
color="success"
class="split-main ml-2"
@click="addIngredient"
>
<v-icon start>
{{ $globals.icons.createAlt }}
</v-icon>
{{ $t('general.add') || 'Add Food' }}
</v-btn>
<!-- Dropdown button -->
<v-menu>
<template #activator="{ props }">
<v-btn
color="success"
class="split-dropdown"
v-bind="props"
>
<v-icon>{{ $globals.icons.chevronDown }}</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.foods"
:title="$t('new-recipe.add-food')"
@click="addIngredient"
/>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.silverwareForkKnife"
:title="$t('new-recipe.add-recipe')"
@click="addRecipe"
/>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.create"
:title="$t('new-recipe.bulk-add')"
@click="showBulkAdd"
/>
</v-list>
</v-menu>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { uuid4 } from "~/composables/use-utils";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const ingredientsWithRecipe = new Map<string, boolean>();
const i18n = useI18n();
const drag = ref(false);
const domBulkAddDialog = ref<InstanceType<typeof RecipeDialogBulkAdd> | null>(null);
const { toggleIsParsing } = usePageState(recipe.value.slug);
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 parserToolTip = computed(() => {
if (hasFoodOrUnit.value) {
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
}
return i18n.t("recipe.parse-ingredients");
});
function showBulkAdd() {
domBulkAddDialog.value?.open();
}
function ingredientIsRecipe(ingredient: RecipeIngredient): boolean {
if (ingredient.referencedRecipe) {
return true;
}
if (ingredient.referenceId) {
return !!ingredientsWithRecipe.get(ingredient.referenceId);
}
return false;
}
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,
quantity: 0,
};
});
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,
quantity: 0,
});
}
}
function addRecipe(recipes: Array<string> | null = null) {
const refId = uuid4();
ingredientsWithRecipe.set(refId, true);
if (recipes?.length) {
const newRecipes = recipes.map((x) => {
return {
referenceId: refId,
title: "",
note: x,
unit: undefined,
referencedRecipe: undefined,
quantity: 1,
};
});
if (newRecipes) {
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
recipe.value.recipeIngredient.push(...newRecipes);
}
}
else {
recipe.value.recipeIngredient.push({
referenceId: refId,
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
referencedRecipe: undefined,
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,
quantity: 0,
});
}
</script>
<style scoped>
.split-main {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-dropdown {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
min-width: 30px;
padding-left: 0;
padding-right: 0;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div>
<RecipeIngredients
:value="recipe.recipeIngredient"
:scale="scale"
:is-cook-mode="isCookMode"
/>
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
<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"
class="px-1"
>
<template #prepend>
<v-checkbox
v-model="recipeTools[index].onHand"
hide-details
class="pt-0 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 setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { useToolStore } from "~/composables/store";
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;
}
interface Props {
recipe: NoUndefinedField<Recipe>;
scale: number;
isCookMode?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isCookMode: false,
});
const { isOwnGroup } = useLoggedInState();
const toolStore = isOwnGroup.value ? useToolStore() : null;
const { user } = usePageUser();
const { isEditMode } = usePageState(props.recipe.slug);
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) => {
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
return { ...tool, onHand } as RecipeToolWithOnHand;
});
}
});
function updateTool(index: number) {
if (user.id && user.householdSlug && toolStore) {
const tool = recipeTools.value[index];
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
if (!tool.householdsWithTool) {
tool.householdsWithTool = [user.householdSlug];
}
else {
tool.householdsWithTool.push(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 {
console.log("no user, skipping server update");
}
}
</script>

View File

@@ -0,0 +1,855 @@
<template>
<section @keyup.ctrl.z="undoMerge">
<!-- Ingredient Link Editor -->
<BaseDialog
v-model="dialog"
:title="$t('recipe.ingredient-linker')"
:icon="$globals.icons.link"
width="100%"
max-width="600px"
max-height="40%"
>
<v-card-text class="pt-4">
<p>
{{ activeText }}
</p>
<v-divider class="my-4" />
<template v-if="Object.keys(groupedUnusedIngredients).length > 0">
<h4 class="ml-1">
{{ $t("recipe.unlinked") }}
</h4>
<template v-for="(ingredients, title) in groupedUnusedIngredients" :key="title">
<h4 v-if="title" class="py-3 ml-1 pl-4">
{{ title }}
</h4>
<v-checkbox-btn
v-for="ing in ingredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="ml-4"
>
<template #label>
<RecipeIngredientHtml :ingredient="ing" :scale="scale" />
</template>
</v-checkbox-btn>
</template>
</template>
<template v-if="Object.keys(groupedUsedIngredients).length > 0">
<h4 class="py-3 ml-1">
{{ $t("recipe.linked-to-other-step") }}
</h4>
<template v-for="(ingredients, title) in groupedUsedIngredients" :key="title">
<h4 v-if="title" class="py-3 ml-1 pl-4">
{{ title }}
</h4>
<v-checkbox-btn
v-for="ing in ingredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="ml-4"
>
<template #label>
<RecipeIngredientHtml :ingredient="ing" :scale="scale" />
</template>
</v-checkbox-btn>
</template>
</template>
</v-card-text>
<v-divider />
<template #card-actions>
<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>
{{ $t("recipe.auto") }}
</BaseButton>
<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>
</template>
</BaseDialog>
<div class="d-flex justify-space-between justify-start">
<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>
<VueDraggable
v-model="instructionList"
:disabled="!isEditForm"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
group: 'recipe-instructions',
ghostClass: 'ghost',
}"
@start="drag = true"
@end="onDragEnd"
>
<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]"
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)"
>
<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': isHovering }, { 'cursor-default': isEditForm }, isChecked(index)]"
:elevation="isHovering ? 12 : 2"
:ripple="false"
@click="toggleDisabled(index)"
>
<v-card-title class="recipe-step-title pt-3" :class="!isChecked(index) ? 'pb-0' : 'pb-3'">
<div class="d-flex align-center w-100">
<v-text-field
v-if="isEditForm"
v-model="step.summary"
class="headline"
hide-details
density="compact"
variant="solo"
flat
:placeholder="$t('recipe.step-index', { step: index + 1 })"
>
<template #prepend>
<v-icon size="26" class="handle">
{{ $globals.icons.arrowUpDown }}
</v-icon>
</template>
</v-text-field>
<div
v-else
class="summary-wrapper"
>
<template v-if="step.summary">
<SafeMarkdown
class="pr-2"
:source="step.summary"
/>
</template>
<template v-else>
<span>
{{ $t('recipe.step-index', { step: index + 1 }) }}
</span>
</template>
</div>
<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-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"
/>
<!-- Content -->
<DropZone @drop="(f) => handleImageDrop(index, f)">
<v-card-text
v-if="isEditForm"
@click="$emit('click-instruction-field', `${index}.text`)"
>
<MarkdownEditor
v-model="instructionList[index]['text']"
v-model:preview="previewStates[index]"
class="mb-2"
:display-preview="false"
:textarea="{
hint: $t('recipe.attach-images-hint'),
persistentHint: true,
}"
/>
<div
v-if="step.ingredientReferences && step.ingredientReferences.length"
class="linked-ingredients-editor"
>
<div
v-for="(linkRef, i) in step.ingredientReferences"
:key="linkRef.referenceId ?? i"
class="mb-1"
>
<RecipeIngredientHtml
v-if="linkRef.referenceId && ingredientLookup[linkRef.referenceId]"
:ingredient="ingredientLookup[linkRef.referenceId]"
:scale="scale"
/>
</div>
</div>
</v-card-text>
</DropZone>
<v-expand-transition>
<div
v-if="!isChecked(index) && !isEditForm"
class="m-0 p-0"
>
<v-card-text class="markdown">
<v-row>
<v-col
v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0"
cols="12"
sm="5"
>
<div class="ml-n4">
<RecipeIngredients
:value="recipe.recipeIngredient.filter((ing) => {
if (!step.ingredientReferences) return false
return step.ingredientReferences.map((ref) => ref.referenceId).includes(ing.referenceId || '')
})"
:scale="scale"
:is-cook-mode="isCookMode"
/>
</div>
</v-col>
<v-divider
v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.display.smAndUp"
vertical
/>
<v-col>
<SafeMarkdown
class="markdown"
:source="step.text"
/>
</v-col>
</v-row>
</v-card-text>
</div>
</v-expand-transition>
</v-card>
</v-hover>
</div>
</TransitionGroup>
</VueDraggable>
<v-divider
v-if="!isCookMode"
class="mt-10 d-flex d-md-none"
/>
</section>
</template>
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
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 type { NoUndefinedField } from "~/lib/api/types/non-generated";
import DropZone from "~/components/global/DropZone.vue";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
import RecipeIngredientHtml from "~/components/Domain/Recipe/RecipeIngredientHtml.vue";
interface MergerHistory {
target: number;
source: number;
targetText: string;
sourceText: string;
}
const instructionList = defineModel<RecipeStep[]>("modelValue", { required: true, default: () => [] });
const assets = defineModel<RecipeAsset[]>("assets", { required: true, default: () => [] });
const props = defineProps({
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
scale: {
type: Number,
default: 1,
},
});
const emit = defineEmits(["click-instruction-field", "update:assets"]);
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
const { extractIngredientReferences } = useExtractIngredientReferences();
const dialog = ref(false);
const disabledSteps = ref<number[]>([]);
const unusedIngredients = ref<RecipeIngredient[]>([]);
const usedIngredients = ref<RecipeIngredient[]>([]);
const showTitleEditor = ref<{ [key: string]: boolean }>({});
// ===============================================================
// UI State Helpers
function hasSectionTitle(title: string | undefined) {
return !(title === null || title === "" || title === undefined);
}
watch(instructionList, (v) => {
disabledSteps.value = [];
v.forEach((element: RecipeStep) => {
if (element.id !== undefined) {
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
}
});
}, { deep: true });
const showCookMode = ref(false);
onMounted(() => {
instructionList.value.forEach((element: RecipeStep) => {
if (element.id !== undefined) {
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
}
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
showCookMode.value = true;
}
showTitleEditor.value = { ...showTitleEditor.value };
});
if (assets.value === undefined) {
emit("update:assets", []);
}
});
function toggleDisabled(stepIndex: number) {
if (isEditForm.value) {
return;
}
if (disabledSteps.value.includes(stepIndex)) {
const index = disabledSteps.value.indexOf(stepIndex);
if (index !== -1) {
disabledSteps.value.splice(index, 1);
}
}
else {
disabledSteps.value.push(stepIndex);
}
}
function isChecked(stepIndex: number) {
if (disabledSteps.value.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 onDragEnd() {
drag.value = false;
}
// ===============================================================
// Ingredient Linker
const activeRefs = ref<string[]>([]);
const activeIndex = ref(0);
const activeText = ref("");
function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
if (!refs) {
instructionList.value[idx].ingredientReferences = [];
refs = instructionList.value[idx].ingredientReferences as IngredientReferences[];
}
activeIndex.value = idx;
activeText.value = text;
setUsedIngredients();
dialog.value = true;
activeRefs.value = refs.map(ref => ref.referenceId ?? "");
}
const availableNextStep = computed(() => activeIndex.value < instructionList.value.length - 1);
function setIngredientIds() {
const instruction = instructionList.value[activeIndex.value];
instruction.ingredientReferences = activeRefs.value.map((ref) => {
return {
referenceId: ref,
};
});
// Update the visibility of the cook mode button
showCookMode.value = false;
instructionList.value.forEach((element) => {
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
showCookMode.value = true;
}
});
dialog.value = false;
}
function saveAndOpenNextLinkIngredients() {
const currentStepIndex = activeIndex.value;
if (!availableNextStep.value) {
return; // no next step, the button calling this function should not be shown
}
setIngredientIds();
const nextStep = instructionList.value[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 } = {};
instructionList.value.forEach((element, idx) => {
if (idx === activeIndex.value) return;
element.ingredientReferences?.forEach((ref) => {
if (ref.referenceId) usedRefs[ref.referenceId] = true;
});
});
usedIngredients.value = props.recipe.recipeIngredient.filter(ing => !!ing.referenceId && ing.referenceId in usedRefs);
unusedIngredients.value = props.recipe.recipeIngredient.filter(ing => !!ing.referenceId && !(ing.referenceId in usedRefs));
}
watch(activeRefs, () => setUsedIngredients());
function autoSetReferences() {
extractIngredientReferences(
props.recipe.recipeIngredient,
activeRefs.value,
activeText.value,
).forEach(ingredient => activeRefs.value.push(ingredient));
}
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);
});
// Map each ingredient's referenceId to its section title
const ingredientSectionTitles = computed(() => {
const titleMap: { [key: string]: string } = {};
let currentTitle = "";
// Go through all ingredients in order
props.recipe.recipeIngredient.forEach((ingredient) => {
if (ingredient.referenceId === undefined) {
return;
}
// If this ingredient has a title, update the current title
if (ingredient.title) {
currentTitle = ingredient.title;
}
// Assign the current title to this ingredient
titleMap[ingredient.referenceId] = currentTitle;
});
return titleMap;
});
const groupedUnusedIngredients = computed((): Record<string, RecipeIngredient[]> => {
const groups: Record<string, RecipeIngredient[]> = {};
// Group ingredients by section title
unusedIngredients.value.forEach((ingredient) => {
if (ingredient.referenceId === undefined) {
return;
}
// Use the section title from the mapping, or fallback to the ingredient's own title
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
(groups[title] ||= []).push(ingredient);
});
return groups;
});
const groupedUsedIngredients = computed((): Record<string, RecipeIngredient[]> => {
const groups: Record<string, RecipeIngredient[]> = {};
usedIngredients.value.forEach((ingredient) => {
if (ingredient.referenceId === undefined) {
return;
}
// Use the section title from the mapping, or fallback to the ingredient's own title
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
(groups[title] ||= []).push(ingredient);
});
return groups;
});
// ===============================================================
// Instruction Merger
const mergeHistory = ref<MergerHistory[]>([]);
function mergeAbove(target: number, source: number) {
if (target < 0) {
return;
}
mergeHistory.value.push({
target,
source,
targetText: instructionList.value[target].text,
sourceText: instructionList.value[source].text,
});
instructionList.value[target].text += " " + instructionList.value[source].text;
instructionList.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;
}
instructionList.value[lastMerge.target].text = lastMerge.targetText;
instructionList.value.splice(lastMerge.source, 0, {
id: uuid4(),
title: "",
text: lastMerge.sourceText,
ingredientReferences: [],
});
}
}
function moveTo(dest: string, source: number) {
if (dest === "top") {
instructionList.value.unshift(instructionList.value.splice(source, 1)[0]);
}
else {
instructionList.value.push(instructionList.value.splice(source, 1)[0]);
}
}
function insert(dest: number) {
instructionList.value.splice(dest, 0, { id: uuid4(), text: "", title: "", 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 < instructionList.value.length; i++) {
if (!(i === index) && hasSectionTitle(instructionList.value[i].title!)) {
break;
}
else {
sectionSteps.push(i);
}
}
const allCollapsed = sectionSteps.every(idx => disabledSteps.value.includes(idx));
if (allCollapsed) {
disabledSteps.value = disabledSteps.value.filter(idx => !sectionSteps.includes(idx));
}
else {
disabledSteps.value = [...disabledSteps.value, ...sectionSteps];
}
}
const drag = ref(false);
// ===============================================================
// Image Uploader
const api = useUserApi();
const { recipeAssetPath } = useStaticRoutes();
const loadingStates = ref<{ [key: number]: boolean }>({});
async function handleImageDrop(index: number, files: File[]) {
if (!files) {
return;
}
// Check if the file is an image
const file = files[0];
if (!file || !file.type.startsWith("image/")) {
return;
}
loadingStates.value[index] = true;
const { data } = await api.recipes.createAsset(props.recipe.slug, {
name: file.name,
icon: "mdi-file-image",
file,
extension: file.name.split(".").pop() || "",
});
loadingStates.value[index] = false;
if (!data) {
return; // TODO: Handle error
}
emit("update:assets", [...assets.value, data]);
const assetUrl = recipeAssetPath(props.recipe.id, data.fileName as string);
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
instructionList.value[index].text += text;
}
function openImageUpload(index: number) {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files) {
await handleImageDrop(index, Array.from(input.files));
input.remove();
}
};
input.click();
}
</script>
<style lang="css" scoped>
.v-card--link:before {
background: none;
}
/** Select all li under .markdown class */
.markdown :deep(ul > li) {
display: list-item;
list-style-type: disc !important;
}
/** Select all li under .markdown class */
.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;
}
.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;
}
.v-text-field :deep(input) {
font-size: 1.5rem;
}
.v-card-text {
font-size: 1rem;
}
.recipe-step-title {
/* Multiline display */
white-space: normal;
line-height: 1.25;
word-break: break-word;
}
.summary-wrapper {
flex: 1 1 auto;
min-width: 0; /* wrapping in flex container */
white-space: normal;
overflow-wrap: anywhere;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<div>
<!-- Recipe Categories -->
<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>
<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-bind="$attrs"
/>
</v-card-text>
</v-card>
<!-- Recipe Tags -->
<v-card
v-if="recipe.tags.length > 0 || isEditForm"
class="mt-4"
>
<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-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-divider class="mx-2" />
<v-card-text>
<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"
/>
<RecipeAssets
v-if="recipe.settings.showAssets"
v-model="recipe.assets"
:edit="isEditForm"
:slug="recipe.slug"
:recipe-id="recipe.id"
/>
</div>
</template>
<script setup lang="ts">
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";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const { isEditForm } = usePageState(recipe.value.slug);
</script>

View File

@@ -0,0 +1,554 @@
<template>
<BaseDialog
:model-value="modelValue"
:title="$t('recipe.parse-ingredients')"
:icon="$globals.icons.fileSign"
disable-submit-on-enter
@update:model-value="emit('update:modelValue', $event)"
>
<v-container fluid class="pa-2 ma-0" style="background-color: rgb(var(--v-theme-background));">
<div v-if="state.loading.parser" class="my-6">
<AppLoader waiting-text="" class="my-6" />
</div>
<div v-else>
<BaseCardSectionTitle :title="$t('recipe.parser.ingredient-parser')">
<div v-if="!state.allReviewed" class="mb-4">
<p>{{ $t("recipe.parser.ingredient-parser-description") }}</p>
<p>{{ $t("recipe.parser.ingredient-parser-final-review-description") }}</p>
</div>
<div class="d-flex flex-wrap align-center">
<div class="text-body-2 mr-2">
{{ $t("recipe.parser.select-parser") }}
</div>
<div class="d-flex align-center">
<BaseOverflowButton
v-model="parser"
:disabled="state.loading.parser"
btn-class="mx-2"
:items="availableParsers"
/>
<v-btn
icon
size="40"
color="info"
:disabled="state.loading.parser"
@click="parseIngredients"
>
<v-icon>{{ $globals.icons.refresh }}</v-icon>
</v-btn>
</div>
</div>
</BaseCardSectionTitle>
<v-card v-if="!state.allReviewed && currentIng">
<v-card-text class="pb-0 mb-0">
<div class="text-center px-8 py-4 mb-6">
<p class="text-h5 font-italic">
{{ currentIng.input }}
</p>
</div>
<div class="d-flex align-center pa-0 ma-0">
<v-icon
:color="(currentIng.confidence?.average || 0) < confidenceThreshold ? 'error' : 'success'"
>
{{ (currentIng.confidence?.average || 0) < confidenceThreshold ? $globals.icons.alert : $globals.icons.check }}
</v-icon>
<span
class="ml-2"
:color="currentIngHasError ? 'error-text' : 'success-text'"
>
{{ $t("recipe.parser.confidence-score") }}: {{ currentIng.confidence ? asPercentage(currentIng.confidence?.average!) : "" }}
</span>
</div>
<RecipeIngredientEditor
v-model="currentIng.ingredient"
:unit-error="!!currentMissingUnit"
:unit-error-tooltip="$t('recipe.parser.this-unit-could-not-be-parsed-automatically')"
:food-error="!!currentMissingFood"
:food-error-tooltip="$t('recipe.parser.this-food-could-not-be-parsed-automatically')"
/>
<v-card-actions>
<v-spacer />
<BaseButton
v-if="currentMissingUnit && !currentIng.ingredient.unit?.id"
color="warning"
size="small"
@click="createMissingUnit"
>
{{ i18n.t("recipe.parser.missing-unit", { unit: currentMissingUnit }) }}
</BaseButton>
<BaseButton
v-if="
currentMissingUnit
&& currentIng.ingredient.unit?.id
&& currentMissingUnit.toLowerCase() != currentIng.ingredient.unit?.name.toLowerCase()
"
color="warning"
size="small"
@click="addMissingUnitAsAlias"
>
{{ i18n.t("recipe.parser.add-text-as-alias-for-item", { text: currentMissingUnit, item: currentIng.ingredient.unit.name }) }}
</BaseButton>
<BaseButton
v-if="currentMissingFood && !currentIng.ingredient.food?.id"
color="warning"
size="small"
@click="createMissingFood"
>
{{ i18n.t("recipe.parser.missing-food", { food: currentMissingFood }) }}
</BaseButton>
<BaseButton
v-if="
currentMissingFood
&& currentIng.ingredient.food?.id
&& currentMissingFood.toLowerCase() != currentIng.ingredient.food?.name.toLowerCase()
"
color="warning"
size="small"
@click="addMissingFoodAsAlias"
>
{{ i18n.t("recipe.parser.add-text-as-alias-for-item", { text: currentMissingFood, item: currentIng.ingredient.food.name }) }}
</BaseButton>
</v-card-actions>
</v-card-text>
</v-card>
<div v-else>
<v-card-title class="text-center pt-0 pb-8">
{{ $t("recipe.parser.review-parsed-ingredients") }}
</v-card-title>
<v-card-text style="max-height: 60vh; overflow-y: auto;">
<VueDraggable
v-model="parsedIngs"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
group: 'recipe-ingredients',
disabled: false,
ghostClass: 'ghost',
}"
class="px-6"
@start="drag = true"
@end="drag = false"
>
<TransitionGroup
type="transition"
>
<v-lazy v-for="(ingredient, index) in parsedIngs" :key="index">
<RecipeIngredientEditor
v-model="ingredient.ingredient"
enable-drag-handle
enable-context-menu
class="list-group-item pb-8"
:delete-disabled="parsedIngs.length <= 1"
@delete="parsedIngs.splice(index, 1)"
@insert-above="insertNewIngredient(index)"
@insert-below="insertNewIngredient(index + 1)"
>
<template #before-divider>
<p v-if="ingredient.input" class="py-0 my-0 text-caption">
{{ $t("recipe.original-text-with-value", { originalText: ingredient.input }) }}
</p>
</template>
</RecipeIngredientEditor>
</v-lazy>
</TransitionGroup>
</VueDraggable>
</v-card-text>
</div>
</div>
</v-container>
<template v-if="!state.loading.parser" #custom-card-action>
<!-- Parse -->
<div v-if="!state.allReviewed" class="d-flex justify-space-between align-center">
<v-checkbox
v-model="currentIngShouldDelete"
color="error"
hide-details
density="compact"
:label="i18n.t('recipe.parser.delete-item')"
class="mr-4"
/>
<BaseButton
:color="currentIngShouldDelete ? 'error' : 'info'"
:icon="currentIngShouldDelete ? $globals.icons.delete : $globals.icons.arrowRightBold"
:icon-right="!currentIngShouldDelete"
:text="$t(currentIngShouldDelete ? 'recipe.parser.delete-item' : 'general.next')"
@click="nextIngredient"
/>
</div>
<!-- Review -->
<div v-else>
<BaseButton
create
:text="$t('general.save')"
:icon="$globals.icons.save"
:loading="state.loading.save"
@click="saveIngs"
/>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe";
import type { Parser } from "~/lib/api/user/recipes/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useUserApi } from "~/composables/api";
import { useIngredientTextParser } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useGlobalI18n } from "~/composables/use-global-i18n";
import { alert } from "~/composables/use-toast";
import { useParsingPreferences } from "~/composables/use-users/preferences";
const props = defineProps<{
modelValue: boolean;
ingredients: NoUndefinedField<RecipeIngredient[]>;
}>();
const { ingredientToParserString } = useIngredientTextParser();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
}>();
const { $appInfo } = useNuxtApp();
const i18n = useGlobalI18n();
const api = useUserApi();
const drag = ref(false);
const unitStore = useUnitStore();
const unitData = useUnitData();
const foodStore = useFoodStore();
const foodData = useFoodData();
const parserPreferences = useParsingPreferences();
const parser = ref<Parser>(parserPreferences.value.parser || "nlp");
const availableParsers = computed(() => {
return [
{
text: i18n.t("recipe.parser.natural-language-processor"),
value: "nlp",
},
{
text: i18n.t("recipe.parser.brute-parser"),
value: "brute",
},
{
text: i18n.t("recipe.parser.openai-parser"),
value: "openai",
hide: !$appInfo.enableOpenai,
},
];
});
/**
* If confidence of parsing is below this threshold,
* we will prompt the user to review the parsed ingredient.
*/
const confidenceThreshold = 0.85;
const parsedIngs = ref<ParsedIngredient[]>([]);
const currentIng = ref<ParsedIngredient | null>(null);
const currentMissingUnit = ref("");
const currentMissingFood = ref("");
const currentIngHasError = computed(() => currentMissingUnit.value || currentMissingFood.value);
const currentIngShouldDelete = ref(false);
const state = reactive({
currentParsedIndex: -1,
allReviewed: false,
loading: {
parser: false,
save: false,
},
});
function shouldReview(ing: ParsedIngredient): boolean {
console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing);
if (ing.ingredient.referencedRecipe) {
console.debug("No review needed for sub-recipe ingredient");
return false;
}
if ((ing.confidence?.average || 0) < confidenceThreshold) {
console.debug("Needs review due to low confidence:", ing.confidence?.average);
return true;
}
const food = ing.ingredient.food;
if (food && !food.id) {
console.debug("Needs review due to missing food ID:", food);
return true;
}
const unit = ing.ingredient.unit;
if (unit && !unit.id) {
console.debug("Needs review due to missing unit ID:", unit);
return true;
}
console.debug("No review needed");
return false;
}
function checkUnit(ing: ParsedIngredient) {
const unit = ing.ingredient.unit?.name;
if (!unit || ing.ingredient.unit?.id) {
currentMissingUnit.value = "";
return;
}
const potentialMatch = createdUnits.get(unit.toLowerCase());
if (potentialMatch) {
ing.ingredient.unit = potentialMatch;
currentMissingUnit.value = "";
return;
}
currentMissingUnit.value = unit;
ing.ingredient.unit = undefined;
}
function checkFood(ing: ParsedIngredient) {
const food = ing.ingredient.food?.name;
if (!food || ing.ingredient.food?.id) {
currentMissingFood.value = "";
return;
}
const potentialMatch = createdFoods.get(food.toLowerCase());
if (potentialMatch) {
ing.ingredient.food = potentialMatch;
currentMissingFood.value = "";
return;
}
currentMissingFood.value = food;
ing.ingredient.food = undefined;
}
function nextIngredient() {
let nextIndex = state.currentParsedIndex;
if (currentIngShouldDelete.value) {
parsedIngs.value.splice(state.currentParsedIndex, 1);
currentIngShouldDelete.value = false;
}
else {
nextIndex += 1;
}
while (nextIndex < parsedIngs.value.length) {
const current = parsedIngs.value[nextIndex];
if (shouldReview(current)) {
state.currentParsedIndex = nextIndex;
currentIng.value = current;
currentIngShouldDelete.value = false;
checkUnit(current);
checkFood(current);
return;
}
nextIndex += 1;
}
// No more to review
state.allReviewed = true;
}
async function parseIngredients() {
if (state.loading.parser) {
return;
}
if (!props.ingredients || props.ingredients.length === 0) {
state.loading.parser = false;
return;
}
state.loading.parser = true;
try {
const ingsAsString = props.ingredients
.filter(ing => !ing.referencedRecipe)
.map(ing => ingredientToParserString(ing));
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
if (error || !data) {
throw new Error("Failed to parse ingredients");
}
parsedIngs.value = data;
const parsed = data ?? [];
const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({
input: ing.note || "",
confidence: {},
ingredient: ing,
}));
parsedIngs.value = [...parsed, ...recipeRefs];
state.currentParsedIndex = -1;
state.allReviewed = false;
createdUnits.clear();
createdFoods.clear();
currentIngShouldDelete.value = false;
nextIngredient();
}
catch (error) {
console.error("Error parsing ingredients:", error);
alert.error(i18n.t("events.something-went-wrong"));
}
finally {
state.loading.parser = false;
}
}
/** Cache of lowercased created units to avoid duplicate creations */
const createdUnits = new Map<string, IngredientUnit>();
/** Cache of lowercased created foods to avoid duplicate creations */
const createdFoods = new Map<string, IngredientFood>();
async function createMissingUnit() {
if (!currentMissingUnit.value) {
return;
}
unitData.reset();
unitData.data.name = currentMissingUnit.value;
let newUnit: IngredientUnit | null = null;
if (createdUnits.has(unitData.data.name)) {
newUnit = createdUnits.get(unitData.data.name)!;
}
else {
newUnit = await unitStore.actions.createOne(unitData.data);
}
if (!newUnit) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.unit = newUnit;
createdUnits.set(newUnit.name.toLowerCase(), newUnit);
currentMissingUnit.value = "";
}
async function createMissingFood() {
if (!currentMissingFood.value) {
return;
}
foodData.reset();
foodData.data.name = currentMissingFood.value;
let newFood: IngredientFood | null = null;
if (createdFoods.has(foodData.data.name)) {
newFood = createdFoods.get(foodData.data.name)!;
}
else {
newFood = await foodStore.actions.createOne(foodData.data);
}
if (!newFood) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.food = newFood;
createdFoods.set(newFood.name.toLowerCase(), newFood);
currentMissingFood.value = "";
}
async function addMissingUnitAsAlias() {
const unit = currentIng.value?.ingredient.unit as IngredientUnit | undefined;
if (!currentMissingUnit.value || !unit?.id) {
return;
}
unit.aliases = unit.aliases || [];
if (unit.aliases.map(a => a.name).includes(currentMissingUnit.value)) {
return;
}
unit.aliases.push({ name: currentMissingUnit.value });
const updated = await unitStore.actions.updateOne(unit);
if (!updated) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.unit = updated;
currentMissingUnit.value = "";
}
async function addMissingFoodAsAlias() {
const food = currentIng.value?.ingredient.food as IngredientFood | undefined;
if (!currentMissingFood.value || !food?.id) {
return;
}
food.aliases = food.aliases || [];
if (food.aliases.map(a => a.name).includes(currentMissingFood.value)) {
return;
}
food.aliases.push({ name: currentMissingFood.value });
const updated = await foodStore.actions.updateOne(food);
if (!updated) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.food = updated;
currentMissingFood.value = "";
}
watch(() => props.modelValue, () => {
if (!props.modelValue) {
return;
}
parseIngredients();
});
watch(parser, () => {
parserPreferences.value.parser = parser.value;
parseIngredients();
});
watch([parsedIngs, () => state.allReviewed], () => {
if (!state.allReviewed) {
return;
}
if (!parsedIngs.value.length) {
insertNewIngredient(0);
}
}, { immediate: true, deep: true });
function asPercentage(num: number | undefined): string {
if (!num) {
return "0%";
}
return Math.round(num * 100).toFixed(2) + "%";
}
function insertNewIngredient(index: number) {
const ing = {
input: "",
confidence: {},
ingredient: {
quantity: 0,
referenceId: uuid4(),
},
} as ParsedIngredient;
parsedIngs.value.splice(index, 0, ing);
}
function saveIngs() {
emit("save", parsedIngs.value.map(x => x.ingredient as NoUndefinedField<RecipeIngredient>));
state.loading.save = true;
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="d-flex justify-space-between align-center pt-2 pb-3">
<RecipeScaleEditButton
v-if="!isEditMode"
v-model.number="scale"
:recipe-servings="recipeServings"
:edit-scale="hasFoodOrUnit && !isEditMode"
/>
</div>
</template>
<script setup lang="ts">
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
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";
const props = defineProps<{ recipe: NoUndefinedField<Recipe> }>();
const scale = defineModel<number>({ default: 1 });
const { isEditMode } = usePageState(props.recipe.slug);
const recipeServings = computed<number>(() => {
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
});
const hasFoodOrUnit = computed(() => {
if (props.recipe.recipeIngredient) {
for (const ingredient of props.recipe.recipeIngredient) {
if (ingredient.food || ingredient.unit) {
return true;
}
}
}
return false;
});
</script>