mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-02 20:15:24 +02:00
feat: ✨ Add brute strategy to ingredient processor (#744)
* fix UI column width * words * update parser to support diff strats * add new model url * make button more visible * fix nutrition error * feat(backend): ✨ add 'brute' strategy for parsing ingredients * satisfy linter * update UI for creation page * feat(backend): ✨ log 422 errors in detail when not in PRODUCTION * add strategy selector Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
parent
60908e5a88
commit
3b920babe3
25 changed files with 961 additions and 131 deletions
|
@ -22,6 +22,43 @@ const routes = {
|
|||
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
|
||||
};
|
||||
|
||||
export type Parser = "nlp" | "brute";
|
||||
|
||||
export interface Confidence {
|
||||
average?: number;
|
||||
comment?: number;
|
||||
name?: number;
|
||||
unit?: number;
|
||||
quantity?: number;
|
||||
food?: number;
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
name: string;
|
||||
description: string;
|
||||
fraction: boolean;
|
||||
abbreviation: string;
|
||||
}
|
||||
|
||||
export interface Food {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Ingredient {
|
||||
title: string;
|
||||
note: string;
|
||||
unit: Unit;
|
||||
food: Food;
|
||||
disableAmount: boolean;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface ParsedIngredient {
|
||||
confidence: Confidence;
|
||||
ingredient: Ingredient;
|
||||
}
|
||||
|
||||
export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
|
||||
baseRoute: string = routes.recipesBase;
|
||||
itemRoute = routes.recipesRecipeSlug;
|
||||
|
@ -84,11 +121,13 @@ export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
|
|||
return await this.requests.delete(routes.recipesSlugCommentsId(slug, id));
|
||||
}
|
||||
|
||||
async parseIngredients(ingredients: Array<string>) {
|
||||
return await this.requests.post(routes.recipesParseIngredients, { ingredients });
|
||||
async parseIngredients(parser: Parser, ingredients: Array<string>) {
|
||||
parser = parser || "nlp";
|
||||
return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients });
|
||||
}
|
||||
|
||||
async parseIngredient(ingredient: string) {
|
||||
return await this.requests.post(routes.recipesParseIngredient, { ingredient });
|
||||
async parseIngredient(parser: Parser, ingredient: string) {
|
||||
parser = parser || "nlp";
|
||||
return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient });
|
||||
}
|
||||
}
|
||||
|
|
82
frontend/components/global/BaseOverflowButton.vue
Normal file
82
frontend/components/global/BaseOverflowButton.vue
Normal file
|
@ -0,0 +1,82 @@
|
|||
<template>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn color="primary" v-bind="attrs" :class="btnClass" v-on="on">
|
||||
<v-icon v-if="activeObj.icon" left>
|
||||
{{ activeObj.icon }}
|
||||
</v-icon>
|
||||
{{ activeObj.text }}
|
||||
<v-icon right>
|
||||
{{ $globals.icons.chevronDown }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item-group v-model="itemGroup">
|
||||
<v-list-item v-for="(item, index) in items" :key="index" @click="setValue(item)">
|
||||
<v-list-item-icon v-if="item.icon">
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list-item-group>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
|
||||
const INPUT_EVENT = "input";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
btnClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const activeObj = ref({
|
||||
text: "DEFAULT",
|
||||
value: "",
|
||||
});
|
||||
|
||||
let startIndex = 0;
|
||||
props.items.forEach((item, index) => {
|
||||
// @ts-ignore
|
||||
if (item.value === props.value) {
|
||||
startIndex = index;
|
||||
|
||||
// @ts-ignore
|
||||
activeObj.value = item;
|
||||
}
|
||||
});
|
||||
const itemGroup = ref(startIndex);
|
||||
|
||||
function setValue(v: any) {
|
||||
context.emit(INPUT_EVENT, v.value);
|
||||
activeObj.value = v;
|
||||
}
|
||||
|
||||
return {
|
||||
activeObj,
|
||||
itemGroup,
|
||||
setValue,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
|
@ -1,18 +1,28 @@
|
|||
<template>
|
||||
<v-container>
|
||||
<v-container class="pa-0">
|
||||
<v-container>
|
||||
<BaseCardSectionTitle title="Ingredients Natural Language Processor">
|
||||
Mealie uses conditional random Conditional Random Fields (CRFs) for parsing and processing ingredients. The
|
||||
model used for ingredients is based off a data set of over 100,000 ingredients from a dataset compiled by the
|
||||
New York Times. Note that as the model is trained in English only, you may have varied results when using the
|
||||
model in other languages. This page is a playground for testing the model.
|
||||
Mealie uses Conditional Random Fields (CRFs) for parsing and processing ingredients. The model used for
|
||||
ingredients is based off a data set of over 100,000 ingredients from a dataset compiled by the New York Times.
|
||||
Note that as the model is trained in English only, you may have varied results when using the model in other
|
||||
languages. This page is a playground for testing the model.
|
||||
|
||||
<p class="pt-3">
|
||||
It's not perfect, but it yields great results in general and is a good starting point for manually parsing
|
||||
ingredients into individual fields.
|
||||
ingredients into individual fields. Alternatively, you can also use the "Brute" processor that uses a pattern
|
||||
matching technique to identify ingredients.
|
||||
</p>
|
||||
</BaseCardSectionTitle>
|
||||
|
||||
<div class="d-flex align-center justify-center justify-md-start flex-wrap">
|
||||
<v-btn-toggle v-model="parser" dense mandatory @change="processIngredient">
|
||||
<v-btn value="nlp"> NLP </v-btn>
|
||||
<v-btn value="brute"> Brute </v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<v-checkbox v-model="showConfidence" class="ml-5" label="Show individual confidence"></v-checkbox>
|
||||
</div>
|
||||
|
||||
<v-card flat>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="ingredient" label="Ingredient Text"> </v-text-field>
|
||||
|
@ -26,22 +36,29 @@
|
|||
</v-card>
|
||||
</v-container>
|
||||
<v-container v-if="results">
|
||||
<v-row class="d-flex">
|
||||
<div v-if="parser !== 'brute' && getConfidence('average')" class="d-flex">
|
||||
<v-chip dark :color="getColor('average')" class="mx-auto mb-2">
|
||||
{{ getConfidence("average") }} Confident
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-center flex-wrap" style="gap: 1.5rem">
|
||||
<template v-for="(prop, index) in properties">
|
||||
<v-col v-if="prop.value" :key="index" xs="12" sm="6" lg="3">
|
||||
<v-card>
|
||||
<div v-if="prop.value" :key="index" class="flex-grow-1">
|
||||
<v-card min-width="200px">
|
||||
<v-card-title> {{ prop.value }} </v-card-title>
|
||||
<v-card-text>
|
||||
{{ prop.subtitle }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-chip v-if="prop.confidence && showConfidence" dark :color="prop.color" class="mt-2">
|
||||
{{ prop.confidence }} Confident
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-container>
|
||||
<v-container class="narrow-container">
|
||||
<v-card-title> Try an example </v-card-title>
|
||||
|
||||
<v-card v-for="(text, idx) in tryText" :key="idx" class="my-2" hover @click="processTryText(text)">
|
||||
<v-card-text> {{ text }} </v-card-text>
|
||||
</v-card>
|
||||
|
@ -50,7 +67,8 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, reactive, ref, toRefs } from "@nuxtjs/composition-api";
|
||||
import { Confidence, Parser } from "~/api/class-interfaces/recipes";
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
|
||||
export default defineComponent({
|
||||
|
@ -62,8 +80,41 @@ export default defineComponent({
|
|||
loading: false,
|
||||
ingredient: "",
|
||||
results: false,
|
||||
parser: "nlp" as Parser,
|
||||
});
|
||||
|
||||
const confidence = ref<Confidence>({});
|
||||
|
||||
function getColor(attribute: string) {
|
||||
const percentage = getConfidence(attribute);
|
||||
|
||||
// @ts-ignore
|
||||
const p_as_num = parseFloat(percentage?.replace("%", ""));
|
||||
|
||||
// Set color based off range
|
||||
if (p_as_num > 75) {
|
||||
return "success";
|
||||
} else if (p_as_num > 60) {
|
||||
return "warning";
|
||||
} else {
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
function getConfidence(attribute: string) {
|
||||
attribute = attribute.toLowerCase();
|
||||
if (!confidence.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const property: number = confidence.value[attribute];
|
||||
if (property) {
|
||||
return `${(property * 100).toFixed(0)}%`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const tryText = [
|
||||
"2 tbsp minced cilantro, leaves and stems",
|
||||
"1 large yellow onion, coarsely chopped",
|
||||
|
@ -78,23 +129,39 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
async function processIngredient() {
|
||||
if (state.ingredient === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
state.loading = true;
|
||||
const { data } = await api.recipes.parseIngredient(state.ingredient);
|
||||
|
||||
const { data } = await api.recipes.parseIngredient(state.parser, state.ingredient);
|
||||
|
||||
if (data) {
|
||||
state.results = true;
|
||||
|
||||
confidence.value = data.confidence;
|
||||
|
||||
// TODO: Remove ts-ignore
|
||||
// ts-ignore because data will likely change significantly once I figure out how to return results
|
||||
// for the parser. For now we'll leave it like this
|
||||
// @ts-ignore
|
||||
properties.comments.value = data.ingredient.note || null;
|
||||
// @ts-ignore
|
||||
properties.quantity.value = data.ingredient.quantity || null;
|
||||
// @ts-ignore
|
||||
properties.unit.value = data.ingredient.unit.name || null;
|
||||
// @ts-ignore
|
||||
properties.food.value = data.ingredient.food.name || null;
|
||||
properties.comment.value = data.ingredient.note || "";
|
||||
properties.quantity.value = data.ingredient.quantity || "";
|
||||
properties.unit.value = data.ingredient.unit.name || "";
|
||||
properties.food.value = data.ingredient.food.name || "";
|
||||
|
||||
for (const property in properties) {
|
||||
const color = getColor(property);
|
||||
const confidence = getConfidence(property);
|
||||
if (color) {
|
||||
// @ts-ignore
|
||||
properties[property].color = color;
|
||||
}
|
||||
if (confidence) {
|
||||
// @ts-ignore
|
||||
properties[property].confidence = confidence;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
@ -102,23 +169,37 @@ export default defineComponent({
|
|||
const properties = reactive({
|
||||
quantity: {
|
||||
subtitle: "Quantity",
|
||||
value: "Value",
|
||||
value: "" as any,
|
||||
color: null,
|
||||
confidence: null,
|
||||
},
|
||||
unit: {
|
||||
subtitle: "Unit",
|
||||
value: "Value",
|
||||
value: "",
|
||||
color: null,
|
||||
confidence: null,
|
||||
},
|
||||
food: {
|
||||
subtitle: "Food",
|
||||
value: "Value",
|
||||
value: "",
|
||||
color: null,
|
||||
confidence: null,
|
||||
},
|
||||
comments: {
|
||||
subtitle: "Comments",
|
||||
value: "Value",
|
||||
comment: {
|
||||
subtitle: "Comment",
|
||||
value: "",
|
||||
color: null,
|
||||
confidence: null,
|
||||
},
|
||||
});
|
||||
|
||||
const showConfidence = ref(false);
|
||||
|
||||
return {
|
||||
showConfidence,
|
||||
getColor,
|
||||
confidence,
|
||||
getConfidence,
|
||||
...toRefs(state),
|
||||
tryText,
|
||||
properties,
|
||||
|
|
|
@ -1,23 +1,69 @@
|
|||
<template>
|
||||
<v-container v-if="recipe">
|
||||
<v-container>
|
||||
<BaseCardSectionTitle title="Ingredients Processor"> </BaseCardSectionTitle>
|
||||
<BaseCardSectionTitle title="Ingredients Processor">
|
||||
To use the ingredient parser, click the "Parse All" button and the process will start. When the processed
|
||||
ingredients are available, you can look through the items and verify that they were parsed correctly. The models
|
||||
confidence score is displayed on the right of the title item. This is an average of all scores and may not be
|
||||
wholey accurate.
|
||||
|
||||
<div class="mt-6">
|
||||
Alerts will be displayed if a matching foods or unit is found but does not exists in the database.
|
||||
</div>
|
||||
<v-divider class="my-4"> </v-divider>
|
||||
<div class="mb-n4">
|
||||
Select Parser
|
||||
<BaseOverflowButton
|
||||
v-model="parser"
|
||||
btn-class="mx-2"
|
||||
:items="[
|
||||
{
|
||||
text: 'Natural Language Processor ',
|
||||
value: 'nlp',
|
||||
},
|
||||
{
|
||||
text: 'Brute Parser',
|
||||
value: 'brute',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</BaseCardSectionTitle>
|
||||
|
||||
<v-card-actions class="justify-end">
|
||||
<BaseButton color="info">
|
||||
<BaseButton color="info" @click="fetchParsed">
|
||||
<template #icon> {{ $globals.icons.foods }}</template>
|
||||
Parse All
|
||||
</BaseButton>
|
||||
<BaseButton save> Save All </BaseButton>
|
||||
<BaseButton save @click="saveAll"> Save All </BaseButton>
|
||||
</v-card-actions>
|
||||
|
||||
</v-card>
|
||||
<v-expansion-panels v-model="panels" multiple>
|
||||
<v-expansion-panel v-for="(ing, index) in ingredients" :key="index">
|
||||
<v-expansion-panel-header class="my-0 py-0">
|
||||
{{ recipe.recipeIngredient[index].note }}
|
||||
<v-expansion-panel v-for="(ing, index) in parsedIng" :key="index">
|
||||
<v-expansion-panel-header class="my-0 py-0" disable-icon-rotate>
|
||||
{{ ing.input }}
|
||||
<template #actions>
|
||||
<v-icon left :color="isError(ing) ? 'error' : 'success'">
|
||||
{{ isError(ing) ? $globals.icons.alert : $globals.icons.check }}
|
||||
</v-icon>
|
||||
<div class="my-auto" :color="isError(ing) ? 'error-text' : 'success-text'">
|
||||
{{ asPercentage(ing.confidence.average) }}
|
||||
</div>
|
||||
</template>
|
||||
</v-expansion-panel-header>
|
||||
<v-expansion-panel-content class="pb-0 mb-0">
|
||||
<RecipeIngredientEditor v-model="ingredients[index]" />
|
||||
<RecipeIngredientEditor v-model="parsedIng[index].ingredient" />
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton
|
||||
v-if="errors[index].foodError && errors[index].foodErrorMessage !== ''"
|
||||
color="warning"
|
||||
small
|
||||
@click="createFood(ing.ingredient.food, index)"
|
||||
>
|
||||
{{ errors[index].foodErrorMessage }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</v-expansion-panel-content>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
@ -26,19 +72,32 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, ref, toRefs, useRoute, watch } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, ref, useRoute, useRouter } from "@nuxtjs/composition-api";
|
||||
import { Food, ParsedIngredient, Parser } from "~/api/class-interfaces/recipes";
|
||||
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
import { useRecipeContext } from "~/composables/use-recipe-context";
|
||||
import { useFoods } from "~/composables/use-recipe-foods";
|
||||
import { useUnits } from "~/composables/use-recipe-units";
|
||||
import { RecipeIngredientUnit } from "~/types/api-types/recipe";
|
||||
|
||||
interface Error {
|
||||
ingredientIndex: number;
|
||||
unitError: Boolean;
|
||||
unitErrorMessage: string;
|
||||
foodError: Boolean;
|
||||
foodErrorMessage: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeIngredientEditor,
|
||||
},
|
||||
setup() {
|
||||
const state = reactive({
|
||||
panels: null,
|
||||
});
|
||||
const panels = ref<number[]>([]);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const slug = route.value.params.slug;
|
||||
const api = useApiSingleton();
|
||||
|
||||
|
@ -48,14 +107,150 @@ export default defineComponent({
|
|||
|
||||
const ingredients = ref<any[]>([]);
|
||||
|
||||
watch(recipe, () => {
|
||||
const copy = recipe?.value?.recipeIngredient || [];
|
||||
ingredients.value = [...copy];
|
||||
});
|
||||
// =========================================================
|
||||
// Parser Logic
|
||||
|
||||
const parser = ref<Parser>("nlp");
|
||||
|
||||
const parsedIng = ref<any[]>([]);
|
||||
|
||||
async function fetchParsed() {
|
||||
if (!recipe.value) {
|
||||
return;
|
||||
}
|
||||
const raw = recipe.value.recipeIngredient.map((ing) => ing.note);
|
||||
const { response, data } = await api.recipes.parseIngredients(parser.value, raw);
|
||||
console.log({ response });
|
||||
|
||||
if (data) {
|
||||
parsedIng.value = data;
|
||||
|
||||
console.log(data);
|
||||
|
||||
// @ts-ignore
|
||||
errors.value = data.map((ing, index: number) => {
|
||||
const unitError = !checkForUnit(ing.ingredient.unit);
|
||||
const foodError = !checkForFood(ing.ingredient.food);
|
||||
|
||||
let unitErrorMessage = "";
|
||||
let foodErrorMessage = "";
|
||||
|
||||
if (unitError || foodError) {
|
||||
if (unitError) {
|
||||
if (ing?.ingredient?.unit?.name) {
|
||||
unitErrorMessage = `Create missing unit '${ing?.ingredient?.unit?.name || "No unit"}'`;
|
||||
}
|
||||
}
|
||||
|
||||
if (foodError) {
|
||||
if (ing?.ingredient?.food?.name) {
|
||||
foodErrorMessage = `Create missing food '${ing.ingredient.food.name || "No food"}'?`;
|
||||
}
|
||||
panels.value.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ingredientIndex: index,
|
||||
unitError,
|
||||
unitErrorMessage,
|
||||
foodError,
|
||||
foodErrorMessage,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isError(ing: ParsedIngredient) {
|
||||
if (!ing?.confidence?.average) {
|
||||
return true;
|
||||
}
|
||||
return !(ing.confidence.average >= 0.75);
|
||||
}
|
||||
|
||||
function asPercentage(num: number) {
|
||||
return Math.round(num * 100).toFixed(2) + "%";
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Food and Ingredient Logic
|
||||
|
||||
const { foods, workingFoodData, actions } = useFoods();
|
||||
const { units } = useUnits();
|
||||
|
||||
const errors = ref<Error[]>([]);
|
||||
|
||||
function checkForUnit(unit: RecipeIngredientUnit) {
|
||||
if (units.value && unit?.name) {
|
||||
return units.value.some((u) => u.name === unit.name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkForFood(food: Food) {
|
||||
if (foods.value && food?.name) {
|
||||
return foods.value.some((f) => f.name === food.name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function createFood(food: Food, index: number) {
|
||||
workingFoodData.name = food.name;
|
||||
await actions.createOne();
|
||||
errors.value[index].foodError = false;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Save All Loginc
|
||||
async function saveAll() {
|
||||
let ingredients = parsedIng.value.map((ing) => {
|
||||
return {
|
||||
...ing.ingredient,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(ingredients);
|
||||
|
||||
ingredients = ingredients.map((ing) => {
|
||||
if (!foods.value || !units.value) {
|
||||
return ing;
|
||||
}
|
||||
// Get food from foods
|
||||
const food = foods.value.find((f) => f.name === ing.food.name);
|
||||
ing.food = food || null;
|
||||
|
||||
// Get unit from units
|
||||
const unit = units.value.find((u) => u.name === ing.unit.name);
|
||||
ing.unit = unit || null;
|
||||
console.log(ing);
|
||||
|
||||
return ing;
|
||||
});
|
||||
|
||||
if (!recipe.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
recipe.value.recipeIngredient = ingredients;
|
||||
const { response } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||
|
||||
if (response?.status === 200) {
|
||||
router.push("/recipe/" + recipe.value.slug);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
api,
|
||||
parser,
|
||||
saveAll,
|
||||
createFood,
|
||||
errors,
|
||||
actions,
|
||||
workingFoodData,
|
||||
isError,
|
||||
panels,
|
||||
asPercentage,
|
||||
fetchParsed,
|
||||
parsedIng,
|
||||
recipe,
|
||||
loading,
|
||||
ingredients,
|
||||
|
@ -69,5 +264,3 @@ export default defineComponent({
|
|||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
|
@ -7,11 +7,8 @@
|
|||
<template #title> Recipe Creation </template>
|
||||
Select one of the various ways to create a recipe
|
||||
</BasePageTitle>
|
||||
<v-tabs v-model="tab">
|
||||
<v-tab href="#url">From URL</v-tab>
|
||||
<v-tab href="#new">Create</v-tab>
|
||||
<v-tab href="#zip">Import Zip</v-tab>
|
||||
</v-tabs>
|
||||
<BaseOverflowButton v-model="tab" rounded class="mx-2" outlined :items="tabs"> </BaseOverflowButton>
|
||||
|
||||
<section>
|
||||
<v-tabs-items v-model="tab" class="mt-10">
|
||||
<v-tab-item value="url" eager>
|
||||
|
@ -127,7 +124,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, reactive, toRefs, ref, useRouter, useContext } from "@nuxtjs/composition-api";
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
export default defineComponent({
|
||||
|
@ -137,6 +134,27 @@ export default defineComponent({
|
|||
loading: false,
|
||||
});
|
||||
|
||||
// @ts-ignore - $globals not found in type definition
|
||||
const { $globals } = useContext();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
icon: $globals.icons.edit,
|
||||
text: "Create Recipe",
|
||||
value: "new",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.link,
|
||||
text: "Import with URL",
|
||||
value: "url",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.zip,
|
||||
text: "Import with .zip",
|
||||
value: "zip",
|
||||
},
|
||||
];
|
||||
|
||||
const api = useApiSingleton();
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -203,6 +221,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
domCreateByName,
|
||||
domUrlForm,
|
||||
newRecipeName,
|
||||
|
|
|
@ -10,11 +10,11 @@
|
|||
<section>
|
||||
<ToggleState tag="article">
|
||||
<template #activator="{ toggle, state }">
|
||||
<v-btn v-if="!state" text color="info" class="mt-2 mb-n3" @click="toggle">
|
||||
<v-btn v-if="!state" color="info" class="mt-2 mb-n3" @click="toggle">
|
||||
<v-icon left>{{ $globals.icons.lock }}</v-icon>
|
||||
{{ $t("settings.change-password") }}
|
||||
</v-btn>
|
||||
<v-btn v-else text color="info" class="mt-2 mb-n3" @click="toggle">
|
||||
<v-btn v-else color="info" class="mt-2 mb-n3" @click="toggle">
|
||||
<v-icon left>{{ $globals.icons.user }}</v-icon>
|
||||
{{ $t("settings.profile") }}
|
||||
</v-btn>
|
||||
|
|
|
@ -30,6 +30,7 @@ import {
|
|||
mdiDotsVertical,
|
||||
mdiPrinter,
|
||||
mdiShareVariant,
|
||||
mdiChevronDown,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
mdiDotsHorizontal,
|
||||
|
@ -210,4 +211,5 @@ export const icons = {
|
|||
forward: mdiArrowRightBoldOutline,
|
||||
back: mdiArrowLeftBoldOutline,
|
||||
slotMachine: mdiSlotMachine,
|
||||
chevronDown: mdiChevronDown,
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue