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:
Philipp Fischbeck 2022-05-25 19:08:32 +02:00 committed by GitHub
parent 30d19c6503
commit 8f7c7c39bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 606 additions and 503 deletions

View File

@ -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.
[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

View File

@ -116,7 +116,7 @@ export default defineComponent({
icon: this.$globals.icons.link,
title: "Import",
subtitle: "Import a recipe by URL",
to: "/recipe/create?tab=url",
to: "/recipe/create/url",
restricted: true,
},
{ divider: true },
@ -124,7 +124,7 @@ export default defineComponent({
icon: this.$globals.icons.edit,
title: "Create",
subtitle: "Create a recipe manually",
to: "/recipe/create?tab=new",
to: "/recipe/create/new",
restricted: true,
},
{ divider: true },

View File

@ -1,6 +1,6 @@
<template>
<div>
<v-container class="narrow-container flex-column pa-0">
<v-container class="flex-column">
<BasePageTitle divider>
<template #header>
<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
<template #content>
<div class="ml-auto">
<BaseOverflowButton v-model="tab" rounded :items="tabs"> </BaseOverflowButton>
<BaseOverflowButton v-model="subpage" rounded :items="subpages"> </BaseOverflowButton>
</div>
</template>
</BasePageTitle>
<section>
<v-tabs-items v-model="tab" class="mt-2">
<!-- 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>
<NuxtChild />
</section>
</v-container>
<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-container>
</AdvancedOnly>
@ -321,39 +27,16 @@
</template>
<script lang="ts">
import {
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 { defineComponent, useRouter, useContext, computed, useRoute } from "@nuxtjs/composition-api";
import { MenuItem } from "~/components/global/BaseOverflowButton.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({
components: { AdvancedOnly, RecipeOrganizerSelector },
components: { AdvancedOnly },
setup() {
const state = reactive({
error: false,
loading: false,
});
const { $globals } = useContext();
const tabs: MenuItem[] = [
const subpages: MenuItem[] = [
{
icon: $globals.icons.link,
text: "Import with URL",
@ -381,185 +64,21 @@ export default defineComponent({
},
];
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 tab = computed({
set(tab: string) {
router.replace({ query: { ...route.value.query, tab } });
const subpage = computed({
set(subpage: string) {
router.push({ path: `/recipe/create/${subpage}`, query: route.value.query });
},
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 {
allTags,
allCategories,
tab,
recipeUrl,
importKeywordsAsTags,
bulkCreate,
bulkUrls,
lockBulkImport,
debugTreeView,
tabs,
domCreateByName,
domUrlForm,
newRecipeName,
newRecipeZip,
debugUrl,
debugData,
createByName,
createByUrl,
createByZip,
...toRefs(state),
validators,
subpages,
subpage,
};
},
head() {
@ -569,9 +88,3 @@ export default defineComponent({
},
});
</script>
<style>
.force-white > a {
color: white !important;
}
</style>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>