mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
* add groupSlug to most routes * fixed more routing issues * fixed jank and incorrect routes * remove public explore links * remove unused groupSlug and explore routes * nuked explore pages * fixed public toolstore bug * fixed various routes missing group slug * restored public app header menu * fix janky login redirect * 404 recipe API call returns to login * removed unused explore layout * force redirect when using the wrong group slug * fixed dead admin links * removed unused middleware from earlier attempt * 🧹 * improve cookbooks sidebar fixed sidebar link not working fixed sidebar link target hide cookbooks header when there are none * added group slug to user * fix $auth typehints * vastly simplified groupSlug logic * allow logged-in users to view other groups * fixed some edgecases that bypassed isOwnGroup * fixed static home ref * 🧹 * fixed redirect logic * lint warning * removed group slug from group and user pages refactored all components to use route groupSlug or user group slug moved some group pages to recipe pages * fixed some bad types * 🧹 * moved groupSlug routes under /g/groupSlug * move /recipe/ to /r/ * fix backend url generation and metadata injection * moved shopping lists to root/other route fixes * changed shared from /recipes/ to /r/ * fixed 404 redirect not awaiting * removed unused import * fix doc links * fix public recipe setting not affecting public API * fixed backend tests * fix nuxt-generate command --------- Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
389 lines
13 KiB
Vue
389 lines
13 KiB
Vue
<template>
|
|
<v-container
|
|
v-if="recipe && recipe.slug && recipe.settings && recipe.recipeIngredient"
|
|
:class="{
|
|
'pa-0': $vuetify.breakpoint.smAndDown,
|
|
}"
|
|
>
|
|
<BannerExperimental />
|
|
|
|
<div v-if="loading">
|
|
<v-spacer />
|
|
<v-progress-circular indeterminate class="" color="primary"> </v-progress-circular>
|
|
{{ loadingText }}
|
|
<v-spacer />
|
|
</div>
|
|
<v-row v-if="!loading">
|
|
<v-col cols="12" sm="7" md="7" lg="7">
|
|
<RecipeOcrEditorPageCanvas
|
|
:image="canvasImage"
|
|
:tsv="tsv"
|
|
@setText="canvasSetText"
|
|
@update-recipe="updateRecipe"
|
|
@close-editor="closeEditor"
|
|
@text-selected="updateSelectedText"
|
|
>
|
|
</RecipeOcrEditorPageCanvas>
|
|
|
|
<RecipeOcrEditorPageHelp />
|
|
</v-col>
|
|
<v-col cols="12" sm="5" md="5" lg="5">
|
|
<v-tabs v-model="tab" fixed-tabs>
|
|
<v-tab key="header">
|
|
{{ $t("general.recipe") }}
|
|
</v-tab>
|
|
<v-tab key="ingredients">
|
|
{{ $t("recipe.ingredients") }}
|
|
</v-tab>
|
|
<v-tab key="instructions">
|
|
{{ $t("recipe.instructions") }}
|
|
</v-tab>
|
|
</v-tabs>
|
|
<v-tabs-items v-model="tab">
|
|
<v-tab-item key="header">
|
|
<v-text-field
|
|
v-model="recipe.name"
|
|
class="my-3"
|
|
:label="$t('recipe.recipe-name')"
|
|
:rules="[validators.required]"
|
|
@focus="selectedRecipeField = 'name'"
|
|
>
|
|
</v-text-field>
|
|
|
|
<div class="d-flex flex-wrap">
|
|
<v-text-field
|
|
v-model="recipe.totalTime"
|
|
class="mx-2"
|
|
:label="$t('recipe.total-time')"
|
|
@click="selectedRecipeField = 'totalTime'"
|
|
></v-text-field>
|
|
<v-text-field
|
|
v-model="recipe.prepTime"
|
|
class="mx-2"
|
|
:label="$t('recipe.prep-time')"
|
|
@click="selectedRecipeField = 'prepTime'"
|
|
></v-text-field>
|
|
<v-text-field
|
|
v-model="recipe.performTime"
|
|
class="mx-2"
|
|
:label="$t('recipe.perform-time')"
|
|
@click="selectedRecipeField = 'performTime'"
|
|
></v-text-field>
|
|
</div>
|
|
|
|
<v-textarea
|
|
v-model="recipe.description"
|
|
auto-grow
|
|
min-height="100"
|
|
:label="$t('recipe.description')"
|
|
@click="selectedRecipeField = 'description'"
|
|
>
|
|
</v-textarea>
|
|
<v-text-field
|
|
v-model="recipe.recipeYield"
|
|
dense
|
|
:label="$t('recipe.servings')"
|
|
@click="selectedRecipeField = 'recipeYield'"
|
|
>
|
|
</v-text-field>
|
|
</v-tab-item>
|
|
<v-tab-item key="ingredients">
|
|
<div class="d-flex justify-end mt-2">
|
|
<RecipeDialogBulkAdd class="ml-1 mr-1" :input-text-prop="canvasSelectedText" @bulk-data="addIngredient" />
|
|
<BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton>
|
|
</div>
|
|
<draggable
|
|
v-if="recipe.recipeIngredient.length > 0"
|
|
v-model="recipe.recipeIngredient"
|
|
handle=".handle"
|
|
v-bind="{
|
|
animation: 200,
|
|
group: 'description',
|
|
disabled: false,
|
|
ghostClass: 'ghost',
|
|
}"
|
|
@start="drag = true"
|
|
@end="drag = false"
|
|
>
|
|
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
|
|
<RecipeIngredientEditor
|
|
v-for="(ingredient, index) in recipe.recipeIngredient"
|
|
:key="ingredient.referenceId"
|
|
v-model="recipe.recipeIngredient[index]"
|
|
class="list-group-item"
|
|
:disable-amount="recipe.settings.disableAmount"
|
|
@delete="recipe.recipeIngredient.splice(index, 1)"
|
|
@clickIngredientField="setSingleIngredient($event, index)"
|
|
/>
|
|
</TransitionGroup>
|
|
</draggable>
|
|
</v-tab-item>
|
|
<v-tab-item key="instructions">
|
|
<div class="d-flex justify-end mt-2">
|
|
<RecipeDialogBulkAdd class="ml-1 mr-1" :input-text-prop="canvasSelectedText" @bulk-data="addStep" />
|
|
<BaseButton @click="addStep()"> {{ $t("general.new") }}</BaseButton>
|
|
</div>
|
|
<RecipePageInstructions
|
|
v-model="recipe.recipeInstructions"
|
|
:ingredients="recipe.recipeIngredient"
|
|
:disable-amount="recipe.settings.disableAmount"
|
|
:edit="true"
|
|
:recipe="recipe"
|
|
:assets.sync="recipe.assets"
|
|
@click-instruction-field="setSingleStep"
|
|
/>
|
|
</v-tab-item>
|
|
</v-tabs-items>
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent, ref, onMounted, reactive, toRefs, useContext, useRouter, computed, useRoute } from "@nuxtjs/composition-api";
|
|
import { until } from "@vueuse/core";
|
|
import { invoke } from "@vueuse/shared";
|
|
import draggable from "vuedraggable";
|
|
import RecipePageInstructions from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInstructions.vue";
|
|
import { useUserApi, useStaticRoutes } from "~/composables/api";
|
|
import { OcrTsvResponse as NullableOcrTsvResponse } from "~/lib/api/types/ocr";
|
|
import { validators } from "~/composables/use-validators";
|
|
import { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
|
|
import { Paths, Leaves, SelectedRecipeLeaves } from "~/types/ocr-types";
|
|
import BannerExperimental from "~/components/global/BannerExperimental.vue";
|
|
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
|
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
|
import RecipeOcrEditorPageCanvas from "~/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageCanvas.vue";
|
|
import RecipeOcrEditorPageHelp from "~/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageHelp.vue";
|
|
import { uuid4 } from "~/composables/use-utils";
|
|
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
|
|
|
// Temporary Shim until we have a better solution
|
|
// https://github.com/phillipdupuis/pydantic-to-typescript/issues/28
|
|
type OcrTsvResponse = NoUndefinedField<NullableOcrTsvResponse>;
|
|
|
|
export default defineComponent({
|
|
components: {
|
|
RecipeIngredientEditor,
|
|
draggable,
|
|
BannerExperimental,
|
|
RecipeDialogBulkAdd,
|
|
RecipePageInstructions,
|
|
RecipeOcrEditorPageCanvas,
|
|
RecipeOcrEditorPageHelp,
|
|
},
|
|
props: {
|
|
recipe: {
|
|
type: Object as () => NoUndefinedField<Recipe>,
|
|
required: true,
|
|
},
|
|
},
|
|
setup(props) {
|
|
const { $auth } = useContext();
|
|
const route = useRoute();
|
|
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
|
|
|
const router = useRouter();
|
|
const api = useUserApi();
|
|
|
|
const tsv = ref<OcrTsvResponse[]>([]);
|
|
|
|
const drag = ref(false);
|
|
|
|
const { recipeAssetPath } = useStaticRoutes();
|
|
|
|
function assetURL(assetName: string) {
|
|
return recipeAssetPath(props.recipe.id, assetName);
|
|
}
|
|
|
|
const state = reactive({
|
|
loading: true,
|
|
loadingText: "Loading recipe...",
|
|
tab: null,
|
|
selectedRecipeField: "" as SelectedRecipeLeaves | "",
|
|
canvasSelectedText: "",
|
|
canvasImage: new Image(),
|
|
});
|
|
|
|
const setPropertyValueByPath = function <T extends Recipe>(object: T, path: Paths<T>, value: any) {
|
|
const a = path.split(".");
|
|
let nextProperty: any = object;
|
|
for (let i = 0, n = a.length - 1; i < n; ++i) {
|
|
const k = a[i];
|
|
if (k in nextProperty) {
|
|
nextProperty = nextProperty[k];
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
nextProperty[a[a.length - 1]] = value;
|
|
};
|
|
|
|
/**
|
|
* This function will find the title of a recipe with the assumption that the title
|
|
* has the biggest ratio of surface area / number of words on the image.
|
|
* @return Returns the text parts of the block with the highest score.
|
|
*/
|
|
function findRecipeTitle() {
|
|
const filtered = tsv.value.filter((element) => element.level === 2 || element.level === 5);
|
|
const blocks = [[]] as OcrTsvResponse[][];
|
|
let blockNum = 1;
|
|
filtered.forEach((element, index, array) => {
|
|
if (index !== 0 && array[index - 1].blockNum !== element.blockNum) {
|
|
blocks.push([]);
|
|
blockNum = element.blockNum;
|
|
}
|
|
blocks[blockNum - 1].push(element);
|
|
});
|
|
|
|
let bestScore = 0;
|
|
let bestBlock = blocks[0];
|
|
blocks.forEach((element) => {
|
|
// element[0] is the block declaration line containing the blocks total dimensions
|
|
// element.length is the number of words (+ 2) contained in that block
|
|
const elementScore = (element[0].height * element[0].width) / element.length; // Prettier is adding useless parenthesis for a mysterious reason
|
|
const elementText = element.map((element) => element.text).join(""); // Identify empty blocks and don't count them
|
|
if (elementScore > bestScore && elementText !== "") {
|
|
bestBlock = element;
|
|
bestScore = elementScore;
|
|
}
|
|
});
|
|
|
|
return bestBlock
|
|
.filter((element) => element.level === 5 && element.conf >= 40)
|
|
.map((element) => {
|
|
return element.text.trim();
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
onMounted(() => {
|
|
invoke(async () => {
|
|
await until(props.recipe).not.toBeNull();
|
|
state.loadingText = "Loading OCR data...";
|
|
|
|
const assetName = props.recipe.assets[0].fileName;
|
|
const imagesrc = assetURL(assetName);
|
|
state.canvasImage.src = imagesrc;
|
|
|
|
const res = await api.ocr.assetToTsv(props.recipe.slug, assetName);
|
|
tsv.value = res.data as OcrTsvResponse[];
|
|
state.loading = false;
|
|
|
|
if (props.recipe.name.match(/New\sOCR\sRecipe(\s\([0-9]+\))?/g)) {
|
|
props.recipe.name = findRecipeTitle();
|
|
}
|
|
});
|
|
});
|
|
|
|
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,
|
|
originalText: "",
|
|
};
|
|
});
|
|
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
function addStep(steps: Array<string> | null = null) {
|
|
if (!props.recipe.recipeInstructions) {
|
|
return;
|
|
}
|
|
|
|
if (steps) {
|
|
const cleanedSteps = steps.map((step) => {
|
|
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
|
|
});
|
|
|
|
props.recipe.recipeInstructions.push(...cleanedSteps);
|
|
} else {
|
|
props.recipe.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] });
|
|
}
|
|
}
|
|
|
|
// EVENT HANDLERS
|
|
|
|
// Canvas component event handlers
|
|
async function updateRecipe() {
|
|
const { data } = await api.recipes.updateOne(props.recipe.slug, props.recipe);
|
|
if (data?.slug) {
|
|
router.push(`/g/${groupSlug.value}/r/${data.slug}`);
|
|
}
|
|
}
|
|
|
|
function closeEditor() {
|
|
router.push(`/g/${groupSlug.value}/r/${props.recipe.slug}`);
|
|
}
|
|
|
|
const canvasSetText = function () {
|
|
if (state.selectedRecipeField !== "") {
|
|
setPropertyValueByPath<Recipe>(props.recipe, state.selectedRecipeField, state.canvasSelectedText);
|
|
}
|
|
};
|
|
|
|
function updateSelectedText(value: string) {
|
|
state.canvasSelectedText = value;
|
|
}
|
|
|
|
// Recipe field selection event handlers
|
|
function setSingleIngredient(f: keyof RecipeIngredient, index: number) {
|
|
state.selectedRecipeField = `recipeIngredient.${index}.${f}` as SelectedRecipeLeaves;
|
|
}
|
|
|
|
// Leaves<RecipeStep[]> will return some function types making eslint very unhappy
|
|
type RecipeStepsLeaves = `${number}.${Leaves<RecipeStep>}`;
|
|
|
|
function setSingleStep(path: RecipeStepsLeaves) {
|
|
state.selectedRecipeField = `recipeInstructions.${path}` as SelectedRecipeLeaves;
|
|
}
|
|
|
|
return {
|
|
...toRefs(state),
|
|
addIngredient,
|
|
addStep,
|
|
drag,
|
|
assetURL,
|
|
updateRecipe,
|
|
closeEditor,
|
|
updateSelectedText,
|
|
tsv,
|
|
validators,
|
|
setSingleIngredient,
|
|
setSingleStep,
|
|
canvasSetText,
|
|
};
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style lang="css">
|
|
.ghost {
|
|
opacity: 0.5;
|
|
}
|
|
</style>
|