mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-06-23 15:31:37 -04:00
refactor: split up recipe create page (#1283)
* refactor: split up recipe create page * add flat card Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
parent
30d19c6503
commit
8f7c7c39bb
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
Mealie offers two main ways to create recipes. You can use the integrated recipe-scraper to create recipes from hundreds of websites, or you can create recipes manually using the recipe editor.
|
Mealie offers two main ways to create recipes. You can use the integrated recipe-scraper to create recipes from hundreds of websites, or you can create recipes manually using the recipe editor.
|
||||||
|
|
||||||
[Demo](https://beta.mealie.io/recipe/create?tab=url){ .md-button .md-button--primary .align-right }
|
[Demo](https://beta.mealie.io/recipe/create/url){ .md-button .md-button--primary .align-right }
|
||||||
|
|
||||||
### Importing Recipes
|
### Importing Recipes
|
||||||
|
|
||||||
|
@ -116,7 +116,7 @@ export default defineComponent({
|
|||||||
icon: this.$globals.icons.link,
|
icon: this.$globals.icons.link,
|
||||||
title: "Import",
|
title: "Import",
|
||||||
subtitle: "Import a recipe by URL",
|
subtitle: "Import a recipe by URL",
|
||||||
to: "/recipe/create?tab=url",
|
to: "/recipe/create/url",
|
||||||
restricted: true,
|
restricted: true,
|
||||||
},
|
},
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
@ -124,7 +124,7 @@ export default defineComponent({
|
|||||||
icon: this.$globals.icons.edit,
|
icon: this.$globals.icons.edit,
|
||||||
title: "Create",
|
title: "Create",
|
||||||
subtitle: "Create a recipe manually",
|
subtitle: "Create a recipe manually",
|
||||||
to: "/recipe/create?tab=new",
|
to: "/recipe/create/new",
|
||||||
restricted: true,
|
restricted: true,
|
||||||
},
|
},
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-container class="narrow-container flex-column pa-0">
|
<v-container class="flex-column">
|
||||||
<BasePageTitle divider>
|
<BasePageTitle divider>
|
||||||
<template #header>
|
<template #header>
|
||||||
<v-img max-height="175" max-width="175" :src="require('~/static/svgs/recipes-create.svg')"></v-img>
|
<v-img max-height="175" max-width="175" :src="require('~/static/svgs/recipes-create.svg')"></v-img>
|
||||||
@ -9,311 +9,17 @@
|
|||||||
Select one of the various ways to create a recipe
|
Select one of the various ways to create a recipe
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="ml-auto">
|
<div class="ml-auto">
|
||||||
<BaseOverflowButton v-model="tab" rounded :items="tabs"> </BaseOverflowButton>
|
<BaseOverflowButton v-model="subpage" rounded :items="subpages"> </BaseOverflowButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<v-tabs-items v-model="tab" class="mt-2">
|
<NuxtChild />
|
||||||
<!-- Create From URL -->
|
|
||||||
<v-tab-item value="url" eager>
|
|
||||||
<v-form ref="domUrlForm" @submit.prevent="createByUrl(recipeUrl, importKeywordsAsTags)">
|
|
||||||
<v-card flat>
|
|
||||||
<v-card-title class="headline"> Scrape Recipe </v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
Scrape a recipe by url. Provide the url for the site you want to scrape, and Mealie will attempt to
|
|
||||||
scrape the recipe from that site and add it to your collection.
|
|
||||||
<v-text-field
|
|
||||||
v-model="recipeUrl"
|
|
||||||
:label="$t('new-recipe.recipe-url')"
|
|
||||||
:prepend-inner-icon="$globals.icons.link"
|
|
||||||
validate-on-blur
|
|
||||||
autofocus
|
|
||||||
filled
|
|
||||||
clearable
|
|
||||||
class="rounded-lg mt-2"
|
|
||||||
rounded
|
|
||||||
:rules="[validators.url]"
|
|
||||||
:hint="$t('new-recipe.url-form-hint')"
|
|
||||||
persistent-hint
|
|
||||||
></v-text-field>
|
|
||||||
<v-checkbox v-model="importKeywordsAsTags" label="Import original keywords as tags">
|
|
||||||
</v-checkbox>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions class="justify-center">
|
|
||||||
<div style="width: 250px">
|
|
||||||
<BaseButton :disabled="recipeUrl === null" rounded block type="submit" :loading="loading" />
|
|
||||||
</div>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-form>
|
|
||||||
<v-expand-transition>
|
|
||||||
<v-alert v-show="error" color="error" class="mt-6 white--text">
|
|
||||||
<v-card-title class="ma-0 pa-0">
|
|
||||||
<v-icon left color="white" x-large> {{ $globals.icons.robot }} </v-icon>
|
|
||||||
{{ $t("new-recipe.error-title") }}
|
|
||||||
</v-card-title>
|
|
||||||
<v-divider class="my-3 mx-2"></v-divider>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
{{ $t("new-recipe.error-details") }}
|
|
||||||
</p>
|
|
||||||
<div class="d-flex row justify-space-around my-3 force-white">
|
|
||||||
<a
|
|
||||||
class="dark"
|
|
||||||
href="https://developers.google.com/search/docs/data-types/recipe"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer nofollow"
|
|
||||||
>
|
|
||||||
{{ $t("new-recipe.google-ld-json-info") }}
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/hay-kot/mealie/issues" target="_blank" rel="noreferrer nofollow">
|
|
||||||
{{ $t("new-recipe.github-issues") }}
|
|
||||||
</a>
|
|
||||||
<a href="https://schema.org/Recipe" target="_blank" rel="noreferrer nofollow">
|
|
||||||
{{ $t("new-recipe.recipe-markup-specification") }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</v-alert>
|
|
||||||
</v-expand-transition>
|
|
||||||
</v-tab-item>
|
|
||||||
|
|
||||||
<!-- Create By Name -->
|
|
||||||
<v-tab-item value="new" eager>
|
|
||||||
<v-card flat>
|
|
||||||
<v-card-title class="headline"> Create Recipe </v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
Create a recipe by providing the name. All recipes must have unique names.
|
|
||||||
<v-form ref="domCreateByName">
|
|
||||||
<v-text-field
|
|
||||||
v-model="newRecipeName"
|
|
||||||
:label="$t('recipe.recipe-name')"
|
|
||||||
:prepend-inner-icon="$globals.icons.primary"
|
|
||||||
validate-on-blur
|
|
||||||
autofocus
|
|
||||||
filled
|
|
||||||
clearable
|
|
||||||
class="rounded-lg mt-2"
|
|
||||||
rounded
|
|
||||||
:rules="[validators.required]"
|
|
||||||
hint="New recipe names must be unique"
|
|
||||||
persistent-hint
|
|
||||||
></v-text-field>
|
|
||||||
</v-form>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions class="justify-center">
|
|
||||||
<div style="width: 250px">
|
|
||||||
<BaseButton
|
|
||||||
:disabled="newRecipeName === ''"
|
|
||||||
rounded
|
|
||||||
block
|
|
||||||
:loading="loading"
|
|
||||||
@click="createByName(newRecipeName)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-tab-item>
|
|
||||||
|
|
||||||
<!-- Create By Zip -->
|
|
||||||
<v-tab-item value="zip" eager>
|
|
||||||
<v-form>
|
|
||||||
<v-card>
|
|
||||||
<v-card-title class="headline"> Import from Zip </v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
Import a single recipe that was exported from another Mealie instance.
|
|
||||||
<v-file-input
|
|
||||||
v-model="newRecipeZip"
|
|
||||||
accept=".zip"
|
|
||||||
label=".zip"
|
|
||||||
filled
|
|
||||||
clearable
|
|
||||||
class="rounded-lg mt-2"
|
|
||||||
rounded
|
|
||||||
truncate-length="100"
|
|
||||||
hint=".zip files must have been exported from Mealie"
|
|
||||||
persistent-hint
|
|
||||||
prepend-icon=""
|
|
||||||
:prepend-inner-icon="$globals.icons.zip"
|
|
||||||
>
|
|
||||||
</v-file-input>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions class="justify-center">
|
|
||||||
<div style="width: 250px">
|
|
||||||
<BaseButton
|
|
||||||
:disabled="newRecipeZip === null"
|
|
||||||
large
|
|
||||||
rounded
|
|
||||||
block
|
|
||||||
:loading="loading"
|
|
||||||
@click="createByZip"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-form>
|
|
||||||
</v-tab-item>
|
|
||||||
|
|
||||||
<!-- Create By Zip -->
|
|
||||||
<v-tab-item value="debug" eager>
|
|
||||||
<v-form ref="domUrlForm" @submit.prevent="debugUrl(recipeUrl)">
|
|
||||||
<v-card flat>
|
|
||||||
<v-card-title class="headline"> Recipe Debugger </v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
Grab the URL of the recipe you want to debug and paste it here. The URL will be scraped by the recipe
|
|
||||||
scraper and the results will be displayed. If you don't see any data returned, the site you are trying
|
|
||||||
to scrape is not supported by Mealie or it's scraper library.
|
|
||||||
<v-text-field
|
|
||||||
v-model="recipeUrl"
|
|
||||||
:label="$t('new-recipe.recipe-url')"
|
|
||||||
validate-on-blur
|
|
||||||
:prepend-inner-icon="$globals.icons.link"
|
|
||||||
autofocus
|
|
||||||
filled
|
|
||||||
clearable
|
|
||||||
rounded
|
|
||||||
class="rounded-lg mt-2"
|
|
||||||
:rules="[validators.url]"
|
|
||||||
:hint="$t('new-recipe.url-form-hint')"
|
|
||||||
persistent-hint
|
|
||||||
></v-text-field>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions class="justify-center">
|
|
||||||
<div style="width: 250px">
|
|
||||||
<BaseButton
|
|
||||||
:disabled="recipeUrl === null"
|
|
||||||
rounded
|
|
||||||
block
|
|
||||||
type="submit"
|
|
||||||
color="info"
|
|
||||||
:loading="loading"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
{{ $globals.icons.robot }}
|
|
||||||
</template>
|
|
||||||
Debug
|
|
||||||
</BaseButton>
|
|
||||||
</div>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-form>
|
|
||||||
</v-tab-item>
|
|
||||||
|
|
||||||
<v-tab-item value="bulk" eager>
|
|
||||||
<v-card flat>
|
|
||||||
<v-card-title class="headline"> Recipe Bulk Importer </v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
The Bulk recipe importer allows you to import multiple recipes at once by queing the sites on the
|
|
||||||
backend and running the task in the background. This can be useful when initially migrating to Mealie,
|
|
||||||
or when you want to import a large number of recipes.
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</v-tab-item>
|
|
||||||
</v-tabs-items>
|
|
||||||
</section>
|
|
||||||
<v-divider class="mt-5"></v-divider>
|
|
||||||
</v-container>
|
|
||||||
|
|
||||||
<v-container tag="section">
|
|
||||||
<!-- Debug Extras -->
|
|
||||||
<section v-if="debugData && tab === 'debug'">
|
|
||||||
<v-checkbox v-model="debugTreeView" label="Tree View"></v-checkbox>
|
|
||||||
<LazyRecipeJsonEditor
|
|
||||||
v-model="debugData"
|
|
||||||
class="primary"
|
|
||||||
:options="{
|
|
||||||
mode: debugTreeView ? 'tree' : 'code',
|
|
||||||
search: false,
|
|
||||||
indentation: 4,
|
|
||||||
mainMenuBar: false,
|
|
||||||
}"
|
|
||||||
height="700px"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<!-- Debug Extras -->
|
|
||||||
<section v-else-if="tab === 'bulk'" class="mt-2">
|
|
||||||
<v-row v-for="(bulkUrl, idx) in bulkUrls" :key="'bulk-url' + idx" class="my-1" dense>
|
|
||||||
<v-col cols="12" xs="12" sm="12" md="12">
|
|
||||||
<v-text-field
|
|
||||||
v-model="bulkUrls[idx].url"
|
|
||||||
:label="$t('new-recipe.recipe-url')"
|
|
||||||
dense
|
|
||||||
single-line
|
|
||||||
validate-on-blur
|
|
||||||
autofocus
|
|
||||||
filled
|
|
||||||
hide-details
|
|
||||||
clearable
|
|
||||||
:prepend-inner-icon="$globals.icons.link"
|
|
||||||
rounded
|
|
||||||
class="rounded-lg"
|
|
||||||
>
|
|
||||||
<template #append>
|
|
||||||
<v-btn color="error" icon x-small @click="bulkUrls.splice(idx, 1)">
|
|
||||||
<v-icon>
|
|
||||||
{{ $globals.icons.delete }}
|
|
||||||
</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
</v-text-field>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" xs="12" sm="6">
|
|
||||||
<RecipeOrganizerSelector
|
|
||||||
v-model="bulkUrls[idx].categories"
|
|
||||||
:items="allCategories || []"
|
|
||||||
selector-type="category"
|
|
||||||
:input-attrs="{
|
|
||||||
filled: true,
|
|
||||||
singleLine: true,
|
|
||||||
dense: true,
|
|
||||||
rounded: true,
|
|
||||||
class: 'rounded-lg',
|
|
||||||
hideDetails: true,
|
|
||||||
clearable: true,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" xs="12" sm="6">
|
|
||||||
<RecipeOrganizerSelector
|
|
||||||
v-model="bulkUrls[idx].tags"
|
|
||||||
:items="allTags || []"
|
|
||||||
selector-type="tag"
|
|
||||||
:input-attrs="{
|
|
||||||
filled: true,
|
|
||||||
singleLine: true,
|
|
||||||
dense: true,
|
|
||||||
rounded: true,
|
|
||||||
class: 'rounded-lg',
|
|
||||||
hideDetails: true,
|
|
||||||
clearable: true,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
<v-card-actions class="justify-end">
|
|
||||||
<BaseButton
|
|
||||||
delete
|
|
||||||
@click="
|
|
||||||
bulkUrls = [];
|
|
||||||
lockBulkImport = false;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</BaseButton>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<BaseButton color="info" @click="bulkUrls.push({ url: '', categories: [], tags: [] })">
|
|
||||||
<template #icon> {{ $globals.icons.createAlt }} </template> New
|
|
||||||
</BaseButton>
|
|
||||||
<BaseButton :disabled="bulkUrls.length === 0 || lockBulkImport" @click="bulkCreate">
|
|
||||||
<template #icon> {{ $globals.icons.check }} </template> Submit
|
|
||||||
</BaseButton>
|
|
||||||
</v-card-actions>
|
|
||||||
</section>
|
</section>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|
||||||
<AdvancedOnly>
|
<AdvancedOnly>
|
||||||
<v-container class="narrow-container d-flex justify-end">
|
<v-container class="d-flex justify-end">
|
||||||
<v-btn outlined rounded to="/group/migrations"> Looking For Migrations? </v-btn>
|
<v-btn outlined rounded to="/group/migrations"> Looking For Migrations? </v-btn>
|
||||||
</v-container>
|
</v-container>
|
||||||
</AdvancedOnly>
|
</AdvancedOnly>
|
||||||
@ -321,39 +27,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { defineComponent, useRouter, useContext, computed, useRoute } from "@nuxtjs/composition-api";
|
||||||
defineComponent,
|
|
||||||
reactive,
|
|
||||||
toRefs,
|
|
||||||
ref,
|
|
||||||
useRouter,
|
|
||||||
useContext,
|
|
||||||
computed,
|
|
||||||
useRoute,
|
|
||||||
} from "@nuxtjs/composition-api";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { onMounted } from "vue-demi";
|
|
||||||
import { useUserApi } from "~/composables/api";
|
|
||||||
import { validators } from "~/composables/use-validators";
|
|
||||||
import { Recipe } from "~/types/api-types/recipe";
|
|
||||||
import { alert } from "~/composables/use-toast";
|
|
||||||
import { VForm } from "~/types/vuetify";
|
|
||||||
import { MenuItem } from "~/components/global/BaseOverflowButton.vue";
|
import { MenuItem } from "~/components/global/BaseOverflowButton.vue";
|
||||||
import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
|
import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
|
||||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
|
||||||
import { useCategories, useTags } from "~/composables/recipes";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: { AdvancedOnly, RecipeOrganizerSelector },
|
components: { AdvancedOnly },
|
||||||
setup() {
|
setup() {
|
||||||
const state = reactive({
|
|
||||||
error: false,
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { $globals } = useContext();
|
const { $globals } = useContext();
|
||||||
|
|
||||||
const tabs: MenuItem[] = [
|
const subpages: MenuItem[] = [
|
||||||
{
|
{
|
||||||
icon: $globals.icons.link,
|
icon: $globals.icons.link,
|
||||||
text: "Import with URL",
|
text: "Import with URL",
|
||||||
@ -381,185 +64,21 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const api = useUserApi();
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
|
const subpage = computed({
|
||||||
if (response?.status !== 201) {
|
set(subpage: string) {
|
||||||
state.error = true;
|
router.push({ path: `/recipe/create/${subpage}`, query: route.value.query });
|
||||||
state.loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/recipe/${response.data}?edit=${edit.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tab = computed({
|
|
||||||
set(tab: string) {
|
|
||||||
router.replace({ query: { ...route.value.query, tab } });
|
|
||||||
},
|
},
|
||||||
get() {
|
get() {
|
||||||
return route.value.query.tab as string;
|
return route.value.path.split("/").pop() ?? "url";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const recipeUrl = computed({
|
|
||||||
set(recipe_import_url: string | null) {
|
|
||||||
if (recipe_import_url !== null) {
|
|
||||||
recipe_import_url = recipe_import_url.trim();
|
|
||||||
router.replace({ query: { ...route.value.query, recipe_import_url } });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
get() {
|
|
||||||
return route.value.query.recipe_import_url as string | null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const importKeywordsAsTags = computed({
|
|
||||||
get() {
|
|
||||||
return route.value.query.import_keywords_as_tags === "1";
|
|
||||||
},
|
|
||||||
set(keywordsAsTags: boolean) {
|
|
||||||
let import_keywords_as_tags = "0"
|
|
||||||
if (keywordsAsTags) {
|
|
||||||
import_keywords_as_tags = "1"
|
|
||||||
}
|
|
||||||
router.replace({query: {...route.value.query, import_keywords_as_tags}})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!recipeUrl.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipeUrl.value.includes("https")) {
|
|
||||||
createByUrl(recipeUrl.value, importKeywordsAsTags.value);
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ===================================================
|
|
||||||
// Recipe Debug URL Scraper
|
|
||||||
|
|
||||||
const debugTreeView = ref(false);
|
|
||||||
|
|
||||||
const debugData = ref<Recipe | null>(null);
|
|
||||||
|
|
||||||
async function debugUrl(url: string | null) {
|
|
||||||
if (url === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.loading = true;
|
|
||||||
|
|
||||||
const { data } = await api.recipes.testCreateOneUrl(url);
|
|
||||||
|
|
||||||
state.loading = false;
|
|
||||||
debugData.value = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================
|
|
||||||
// Recipe URL Import
|
|
||||||
const domUrlForm = ref<VForm | null>(null);
|
|
||||||
|
|
||||||
async function createByUrl(url: string, importKeywordsAsTags: boolean) {
|
|
||||||
|
|
||||||
if (url === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!domUrlForm.value?.validate() || url === "") {
|
|
||||||
console.log("Invalid URL", url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.loading = true;
|
|
||||||
const { response } = await api.recipes.createOneByUrl(url, importKeywordsAsTags);
|
|
||||||
handleResponse(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================
|
|
||||||
// Recipe Create By Name
|
|
||||||
const newRecipeName = ref("");
|
|
||||||
const domCreateByName = ref<VForm | null>(null);
|
|
||||||
|
|
||||||
async function createByName(name: string) {
|
|
||||||
if (!domCreateByName.value?.validate() || name === "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { response } = await api.recipes.createOne({ name });
|
|
||||||
// TODO createOne claims to return a Recipe, but actually the API only returns a string
|
|
||||||
// @ts-ignore See above
|
|
||||||
handleResponse(response, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================
|
|
||||||
// Recipe Import From Zip File
|
|
||||||
const newRecipeZip = ref<File | null>(null);
|
|
||||||
const newRecipeZipFileName = "archive";
|
|
||||||
|
|
||||||
async function createByZip() {
|
|
||||||
if (!newRecipeZip.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append(newRecipeZipFileName, newRecipeZip.value);
|
|
||||||
|
|
||||||
const { response } = await api.upload.file("/api/recipes/create-from-zip", formData);
|
|
||||||
handleResponse(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================
|
|
||||||
// Bulk Importer
|
|
||||||
|
|
||||||
const bulkUrls = ref([{ url: "", categories: [], tags: [] }]);
|
|
||||||
const lockBulkImport = ref(false);
|
|
||||||
|
|
||||||
async function bulkCreate() {
|
|
||||||
if (bulkUrls.value.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { response } = await api.recipes.createManyByUrl({ imports: bulkUrls.value });
|
|
||||||
|
|
||||||
if (response?.status === 202) {
|
|
||||||
alert.success("Bulk Import process has started");
|
|
||||||
lockBulkImport.value = true;
|
|
||||||
} else {
|
|
||||||
alert.error("Bulk import process has failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { allTags, useAsyncGetAll: getAllTags } = useTags();
|
|
||||||
const { allCategories, useAsyncGetAll: getAllCategories } = useCategories();
|
|
||||||
|
|
||||||
getAllTags();
|
|
||||||
getAllCategories();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allTags,
|
subpages,
|
||||||
allCategories,
|
subpage,
|
||||||
tab,
|
|
||||||
recipeUrl,
|
|
||||||
importKeywordsAsTags,
|
|
||||||
bulkCreate,
|
|
||||||
bulkUrls,
|
|
||||||
lockBulkImport,
|
|
||||||
debugTreeView,
|
|
||||||
tabs,
|
|
||||||
domCreateByName,
|
|
||||||
domUrlForm,
|
|
||||||
newRecipeName,
|
|
||||||
newRecipeZip,
|
|
||||||
debugUrl,
|
|
||||||
debugData,
|
|
||||||
createByName,
|
|
||||||
createByUrl,
|
|
||||||
createByZip,
|
|
||||||
...toRefs(state),
|
|
||||||
validators,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head() {
|
head() {
|
||||||
@ -569,9 +88,3 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.force-white > a {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
143
frontend/pages/recipe/create/bulk.vue
Normal file
143
frontend/pages/recipe/create/bulk.vue
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-card flat>
|
||||||
|
<v-card-title class="headline"> Recipe Bulk Importer </v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
The Bulk recipe importer allows you to import multiple recipes at once by queing the sites on the backend and
|
||||||
|
running the task in the background. This can be useful when initially migrating to Mealie, or when you want to
|
||||||
|
import a large number of recipes.
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
<section class="mt-2">
|
||||||
|
<v-row v-for="(bulkUrl, idx) in bulkUrls" :key="'bulk-url' + idx" class="my-1" dense>
|
||||||
|
<v-col cols="12" xs="12" sm="12" md="12">
|
||||||
|
<v-text-field
|
||||||
|
v-model="bulkUrls[idx].url"
|
||||||
|
:label="$t('new-recipe.recipe-url')"
|
||||||
|
dense
|
||||||
|
single-line
|
||||||
|
validate-on-blur
|
||||||
|
autofocus
|
||||||
|
filled
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
:prepend-inner-icon="$globals.icons.link"
|
||||||
|
rounded
|
||||||
|
class="rounded-lg"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<v-btn color="error" icon x-small @click="bulkUrls.splice(idx, 1)">
|
||||||
|
<v-icon>
|
||||||
|
{{ $globals.icons.delete }}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" xs="12" sm="6">
|
||||||
|
<RecipeOrganizerSelector
|
||||||
|
v-model="bulkUrls[idx].categories"
|
||||||
|
:items="allCategories || []"
|
||||||
|
selector-type="category"
|
||||||
|
:input-attrs="{
|
||||||
|
filled: true,
|
||||||
|
singleLine: true,
|
||||||
|
dense: true,
|
||||||
|
rounded: true,
|
||||||
|
class: 'rounded-lg',
|
||||||
|
hideDetails: true,
|
||||||
|
clearable: true,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" xs="12" sm="6">
|
||||||
|
<RecipeOrganizerSelector
|
||||||
|
v-model="bulkUrls[idx].tags"
|
||||||
|
:items="allTags || []"
|
||||||
|
selector-type="tag"
|
||||||
|
:input-attrs="{
|
||||||
|
filled: true,
|
||||||
|
singleLine: true,
|
||||||
|
dense: true,
|
||||||
|
rounded: true,
|
||||||
|
class: 'rounded-lg',
|
||||||
|
hideDetails: true,
|
||||||
|
clearable: true,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<BaseButton
|
||||||
|
delete
|
||||||
|
@click="
|
||||||
|
bulkUrls = [];
|
||||||
|
lockBulkImport = false;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</BaseButton>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<BaseButton color="info" @click="bulkUrls.push({ url: '', categories: [], tags: [] })">
|
||||||
|
<template #icon> {{ $globals.icons.createAlt }} </template> New
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton :disabled="bulkUrls.length === 0 || lockBulkImport" @click="bulkCreate">
|
||||||
|
<template #icon> {{ $globals.icons.check }} </template> Submit
|
||||||
|
</BaseButton>
|
||||||
|
</v-card-actions>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, reactive, toRefs, ref } from "@nuxtjs/composition-api";
|
||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||||
|
import { useCategories, useTags } from "~/composables/recipes";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: { RecipeOrganizerSelector },
|
||||||
|
setup() {
|
||||||
|
const state = reactive({
|
||||||
|
error: false,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
|
const bulkUrls = ref([{ url: "", categories: [], tags: [] }]);
|
||||||
|
const lockBulkImport = ref(false);
|
||||||
|
|
||||||
|
async function bulkCreate() {
|
||||||
|
if (bulkUrls.value.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response } = await api.recipes.createManyByUrl({ imports: bulkUrls.value });
|
||||||
|
|
||||||
|
if (response?.status === 202) {
|
||||||
|
alert.success("Bulk Import process has started");
|
||||||
|
lockBulkImport.value = true;
|
||||||
|
} else {
|
||||||
|
alert.error("Bulk import process has failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { allTags, useAsyncGetAll: getAllTags } = useTags();
|
||||||
|
const { allCategories, useAsyncGetAll: getAllCategories } = useCategories();
|
||||||
|
|
||||||
|
getAllTags();
|
||||||
|
getAllCategories();
|
||||||
|
|
||||||
|
return {
|
||||||
|
allTags,
|
||||||
|
allCategories,
|
||||||
|
bulkCreate,
|
||||||
|
bulkUrls,
|
||||||
|
lockBulkImport,
|
||||||
|
...toRefs(state),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
110
frontend/pages/recipe/create/debug.vue
Normal file
110
frontend/pages/recipe/create/debug.vue
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-form ref="domUrlForm" @submit.prevent="debugUrl(recipeUrl)">
|
||||||
|
<v-card flat>
|
||||||
|
<v-card-title class="headline"> Recipe Debugger </v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
Grab the URL of the recipe you want to debug and paste it here. The URL will be scraped by the recipe scraper
|
||||||
|
and the results will be displayed. If you don't see any data returned, the site you are trying to scrape is
|
||||||
|
not supported by Mealie or it's scraper library.
|
||||||
|
<v-text-field
|
||||||
|
v-model="recipeUrl"
|
||||||
|
:label="$t('new-recipe.recipe-url')"
|
||||||
|
validate-on-blur
|
||||||
|
:prepend-inner-icon="$globals.icons.link"
|
||||||
|
autofocus
|
||||||
|
filled
|
||||||
|
clearable
|
||||||
|
rounded
|
||||||
|
class="rounded-lg mt-2"
|
||||||
|
:rules="[validators.url]"
|
||||||
|
:hint="$t('new-recipe.url-form-hint')"
|
||||||
|
persistent-hint
|
||||||
|
></v-text-field>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="justify-center">
|
||||||
|
<div style="width: 250px">
|
||||||
|
<BaseButton :disabled="recipeUrl === null" rounded block type="submit" color="info" :loading="loading">
|
||||||
|
<template #icon>
|
||||||
|
{{ $globals.icons.robot }}
|
||||||
|
</template>
|
||||||
|
Debug
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-form>
|
||||||
|
<section v-if="debugData">
|
||||||
|
<v-checkbox v-model="debugTreeView" label="Tree View"></v-checkbox>
|
||||||
|
<LazyRecipeJsonEditor
|
||||||
|
v-model="debugData"
|
||||||
|
class="primary"
|
||||||
|
:options="{
|
||||||
|
mode: debugTreeView ? 'tree' : 'code',
|
||||||
|
search: false,
|
||||||
|
indentation: 4,
|
||||||
|
mainMenuBar: false,
|
||||||
|
}"
|
||||||
|
height="700px"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, reactive, toRefs, ref, useRouter, computed, useRoute } from "@nuxtjs/composition-api";
|
||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { validators } from "~/composables/use-validators";
|
||||||
|
import { Recipe } from "~/types/api-types/recipe";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
setup() {
|
||||||
|
const state = reactive({
|
||||||
|
error: false,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = useUserApi();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const recipeUrl = computed({
|
||||||
|
set(recipe_import_url: string | null) {
|
||||||
|
if (recipe_import_url !== null) {
|
||||||
|
recipe_import_url = recipe_import_url.trim();
|
||||||
|
router.replace({ query: { ...route.value.query, recipe_import_url } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return route.value.query.recipe_import_url as string | null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const debugTreeView = ref(false);
|
||||||
|
|
||||||
|
const debugData = ref<Recipe | null>(null);
|
||||||
|
|
||||||
|
async function debugUrl(url: string | null) {
|
||||||
|
if (url === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loading = true;
|
||||||
|
|
||||||
|
const { data } = await api.recipes.testCreateOneUrl(url);
|
||||||
|
|
||||||
|
state.loading = false;
|
||||||
|
debugData.value = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipeUrl,
|
||||||
|
debugTreeView,
|
||||||
|
debugUrl,
|
||||||
|
debugData,
|
||||||
|
...toRefs(state),
|
||||||
|
validators,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
18
frontend/pages/recipe/create/index.vue
Normal file
18
frontend/pages/recipe/create/index.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, onMounted, useRouter } from "@nuxtjs/composition-api";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
onMounted(() => {
|
||||||
|
// Force redirect to first valid page
|
||||||
|
router.replace("/recipe/create/url");
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
82
frontend/pages/recipe/create/new.vue
Normal file
82
frontend/pages/recipe/create/new.vue
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<v-card flat>
|
||||||
|
<v-card-title class="headline"> Create Recipe </v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
Create a recipe by providing the name. All recipes must have unique names.
|
||||||
|
<v-form ref="domCreateByName">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newRecipeName"
|
||||||
|
:label="$t('recipe.recipe-name')"
|
||||||
|
:prepend-inner-icon="$globals.icons.primary"
|
||||||
|
validate-on-blur
|
||||||
|
autofocus
|
||||||
|
filled
|
||||||
|
clearable
|
||||||
|
class="rounded-lg mt-2"
|
||||||
|
rounded
|
||||||
|
:rules="[validators.required]"
|
||||||
|
hint="New recipe names must be unique"
|
||||||
|
persistent-hint
|
||||||
|
></v-text-field>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="justify-center">
|
||||||
|
<div style="width: 250px">
|
||||||
|
<BaseButton
|
||||||
|
:disabled="newRecipeName === ''"
|
||||||
|
rounded
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
@click="createByName(newRecipeName)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { validators } from "~/composables/use-validators";
|
||||||
|
import { VForm } from "~/types/vuetify";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
setup() {
|
||||||
|
const state = reactive({
|
||||||
|
error: false,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
const api = useUserApi();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
|
||||||
|
if (response?.status !== 201) {
|
||||||
|
state.error = true;
|
||||||
|
state.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/recipe/${response.data}?edit=${edit.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRecipeName = ref("");
|
||||||
|
const domCreateByName = ref<VForm | null>(null);
|
||||||
|
|
||||||
|
async function createByName(name: string) {
|
||||||
|
if (!domCreateByName.value?.validate() || name === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { response } = await api.recipes.createOne({ name });
|
||||||
|
// TODO createOne claims to return a Recipe, but actually the API only returns a string
|
||||||
|
// @ts-ignore See above
|
||||||
|
handleResponse(response, true);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
domCreateByName,
|
||||||
|
newRecipeName,
|
||||||
|
createByName,
|
||||||
|
...toRefs(state),
|
||||||
|
validators,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
159
frontend/pages/recipe/create/url.vue
Normal file
159
frontend/pages/recipe/create/url.vue
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-form ref="domUrlForm" @submit.prevent="createByUrl(recipeUrl, importKeywordsAsTags)">
|
||||||
|
<v-card flat>
|
||||||
|
<v-card-title class="headline"> Scrape Recipe </v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
Scrape a recipe by url. Provide the url for the site you want to scrape, and Mealie will attempt to scrape the
|
||||||
|
recipe from that site and add it to your collection.
|
||||||
|
<v-text-field
|
||||||
|
v-model="recipeUrl"
|
||||||
|
:label="$t('new-recipe.recipe-url')"
|
||||||
|
:prepend-inner-icon="$globals.icons.link"
|
||||||
|
validate-on-blur
|
||||||
|
autofocus
|
||||||
|
filled
|
||||||
|
clearable
|
||||||
|
class="rounded-lg mt-2"
|
||||||
|
rounded
|
||||||
|
:rules="[validators.url]"
|
||||||
|
:hint="$t('new-recipe.url-form-hint')"
|
||||||
|
persistent-hint
|
||||||
|
></v-text-field>
|
||||||
|
<v-checkbox v-model="importKeywordsAsTags" label="Import original keywords as tags"> </v-checkbox>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="justify-center">
|
||||||
|
<div style="width: 250px">
|
||||||
|
<BaseButton :disabled="recipeUrl === null" rounded block type="submit" :loading="loading" />
|
||||||
|
</div>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-form>
|
||||||
|
<v-expand-transition>
|
||||||
|
<v-alert v-show="error" color="error" class="mt-6 white--text">
|
||||||
|
<v-card-title class="ma-0 pa-0">
|
||||||
|
<v-icon left color="white" x-large> {{ $globals.icons.robot }} </v-icon>
|
||||||
|
{{ $t("new-recipe.error-title") }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider class="my-3 mx-2"></v-divider>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{ $t("new-recipe.error-details") }}
|
||||||
|
</p>
|
||||||
|
<div class="d-flex row justify-space-around my-3 force-white">
|
||||||
|
<a
|
||||||
|
class="dark"
|
||||||
|
href="https://developers.google.com/search/docs/data-types/recipe"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer nofollow"
|
||||||
|
>
|
||||||
|
{{ $t("new-recipe.google-ld-json-info") }}
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/hay-kot/mealie/issues" target="_blank" rel="noreferrer nofollow">
|
||||||
|
{{ $t("new-recipe.github-issues") }}
|
||||||
|
</a>
|
||||||
|
<a href="https://schema.org/Recipe" target="_blank" rel="noreferrer nofollow">
|
||||||
|
{{ $t("new-recipe.recipe-markup-specification") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
</v-expand-transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, reactive, toRefs, ref, useRouter, computed, useRoute } from "@nuxtjs/composition-api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { onMounted } from "vue-demi";
|
||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { validators } from "~/composables/use-validators";
|
||||||
|
import { VForm } from "~/types/vuetify";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
setup() {
|
||||||
|
const state = reactive({
|
||||||
|
error: false,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = useUserApi();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
|
||||||
|
if (response?.status !== 201) {
|
||||||
|
state.error = true;
|
||||||
|
state.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/recipe/${response.data}?edit=${edit.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeUrl = computed({
|
||||||
|
set(recipe_import_url: string | null) {
|
||||||
|
if (recipe_import_url !== null) {
|
||||||
|
recipe_import_url = recipe_import_url.trim();
|
||||||
|
router.replace({ query: { ...route.value.query, recipe_import_url } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return route.value.query.recipe_import_url as string | null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const importKeywordsAsTags = computed({
|
||||||
|
get() {
|
||||||
|
return route.value.query.import_keywords_as_tags === "1";
|
||||||
|
},
|
||||||
|
set(keywordsAsTags: boolean) {
|
||||||
|
let import_keywords_as_tags = "0";
|
||||||
|
if (keywordsAsTags) {
|
||||||
|
import_keywords_as_tags = "1";
|
||||||
|
}
|
||||||
|
router.replace({ query: { ...route.value.query, import_keywords_as_tags } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!recipeUrl.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipeUrl.value.includes("https")) {
|
||||||
|
createByUrl(recipeUrl.value, importKeywordsAsTags.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const domUrlForm = ref<VForm | null>(null);
|
||||||
|
|
||||||
|
async function createByUrl(url: string, importKeywordsAsTags: boolean) {
|
||||||
|
if (url === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domUrlForm.value?.validate() || url === "") {
|
||||||
|
console.log("Invalid URL", url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.loading = true;
|
||||||
|
const { response } = await api.recipes.createOneByUrl(url, importKeywordsAsTags);
|
||||||
|
handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipeUrl,
|
||||||
|
importKeywordsAsTags,
|
||||||
|
domUrlForm,
|
||||||
|
createByUrl,
|
||||||
|
...toRefs(state),
|
||||||
|
validators,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.force-white > a {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
</style>
|
78
frontend/pages/recipe/create/zip.vue
Normal file
78
frontend/pages/recipe/create/zip.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<v-form>
|
||||||
|
<v-card flat>
|
||||||
|
<v-card-title class="headline"> Import from Zip </v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
Import a single recipe that was exported from another Mealie instance.
|
||||||
|
<v-file-input
|
||||||
|
v-model="newRecipeZip"
|
||||||
|
accept=".zip"
|
||||||
|
label=".zip"
|
||||||
|
filled
|
||||||
|
clearable
|
||||||
|
class="rounded-lg mt-2"
|
||||||
|
rounded
|
||||||
|
truncate-length="100"
|
||||||
|
hint=".zip files must have been exported from Mealie"
|
||||||
|
persistent-hint
|
||||||
|
prepend-icon=""
|
||||||
|
:prepend-inner-icon="$globals.icons.zip"
|
||||||
|
>
|
||||||
|
</v-file-input>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="justify-center">
|
||||||
|
<div style="width: 250px">
|
||||||
|
<BaseButton :disabled="newRecipeZip === null" large rounded block :loading="loading" @click="createByZip" />
|
||||||
|
</div>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { validators } from "~/composables/use-validators";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
setup() {
|
||||||
|
const state = reactive({
|
||||||
|
error: false,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
const api = useUserApi();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
|
||||||
|
if (response?.status !== 201) {
|
||||||
|
state.error = true;
|
||||||
|
state.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/recipe/${response.data}?edit=${edit.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRecipeZip = ref<File | null>(null);
|
||||||
|
const newRecipeZipFileName = "archive";
|
||||||
|
|
||||||
|
async function createByZip() {
|
||||||
|
if (!newRecipeZip.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(newRecipeZipFileName, newRecipeZip.value);
|
||||||
|
|
||||||
|
const { response } = await api.upload.file("/api/recipes/create-from-zip", formData);
|
||||||
|
handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
newRecipeZip,
|
||||||
|
createByZip,
|
||||||
|
...toRefs(state),
|
||||||
|
validators,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
Loading…
x
Reference in New Issue
Block a user