Tutorial Membuat Aplikasi Resep Menggunakan Vue Js Dan Vuex
Dalam tutorial ini, saya akan menunjukkan cara membuat Aplikasi Resep yang elegan menggunakan Vue2, Vuex, Vuetify, dan Cosmic JS. Demi memahami cara menggunakan Restful API, tutorial ini akan menunjukkan cara membuat permintaan AJAX (XHR) ke Cosmic JS API untuk mengambil, menambah, memperbarui, dan menghapus data/media di bucket Cosmic JS. Mari kita mulai.
Prasyarat
Anda membutuhkan Node JS dan npm. Pastikan Anda sudah memilikinya sebelum memulai.
Persiapan
Melakukan semuanya menggunakan repo git yang ada
Pertama-tama, Anda harus yakin Anda telah menginstal node > 6.x, lalu jalankan perintah berikut:
npm install -g vue-cli
git clone https://github.com/cosmicjs/recipe-app/
cd recipe-app
npm install
npm run dev
Jendela browser akan terbuka secara otomatis setelah Anda menjalankan perintah terakhir.
package.json
akan terlihat seperti ini.
{
"name": "recipe-app",
"description": "A Vue.js project",
"version": "1.0.0",
"author": "Jazib Sawar ",
"private": true,
"scripts": {
"dev": "cross-env NODE_ENV=development webpack-dev-server --open --inline --hot",
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules"
},
"dependencies": {
"cosmicjs": "^2.4.11",
"js-beautify": "^1.6.14",
"lodash": "^4.17.4",
"vee-validate": "^2.0.0-rc.17",
"vue": "^2.4.2",
"vue-router": "^2.7.0",
"vue-wysiwyg": "^1.2.6",
"vuetify": "^0.15.7",
"vuex": "^2.4.0"
},
"devDependencies": {
"babel-core": "^6.0.0",
"babel-loader": "^6.0.0",
"babel-plugin-add-filehash": "^6.9.4",
"babel-plugin-transform-imports": "^1.4.1",
"babel-preset-env": "^1.5.1",
"babel-preset-es2015": "^6.24.1",
"babel-preset-stage-2": "^6.24.1",
"cross-env": "^3.0.0",
"css-loader": "^0.25.0",
"file-loader": "^0.9.0",
"node-sass": "^4.5.0",
"sass-loader": "^5.0.1",
"style-loader": "^0.13.1",
"stylus": "^0.54.5",
"stylus-loader": "^3.0.1",
"vue-loader": "^12.1.0",
"vue-template-compiler": "^2.4.2",
"webpack": "^2.6.1",
"webpack-dev-server": "^2.4.5"
}
}
Apa yang kami instal dan mengapa
- Kami akan menggunakan pustaka vue dan vuex untuk membuat komponen dan mengelola status.
- Kami menggunakan paket vue-router untuk bernavigasi di antara komponen kami.
- Kami menggunakan paket vuetify untuk membuat tata letak yang indah menggunakan komponen vue.
- Kami akan menggunakan perpustakaan cosmicjs untuk menangani permintaan kami ke ember Cosmic JS kami.
- vue-wysiwyg digunakan untuk editor & vee-validate digunakan untuk validasi formulir.
Membangun aplikasi kami
Sekarang kita akan mengatur index.html kita di direktori root kita di mana kita akan mengubah favicon dan Anda juga dapat menambahkan tag meta.
Di bawah ini adalah file index.html. Hanya blok kode yang penting di sini adalah menyertakan skrip build <script src=”./dist/build.js”></script> di akhir tag body dan membuat elemen <div id=“app”></div > di mana vue akan mem-bootstrap aplikasi Anda.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Recipes App</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' rel="stylesheet" type="text/css">
<link href='./public/font-awesome-4.7.0/css/font-awesome.min.css' rel="stylesheet" type="text/css">
<link rel="icon" type="image/png" href="./public/favicon-32x32.png" sizes="32x32">
</head>
<body>
<div id="app"></div>
<script src="./dist/build.js"></script>
</body>
</html>
Siapkan main.js
Seperti yang Anda ketahui main.js adalah file utama yang membuat instance vue dan merender komponen pertama.
import Vue from 'vue'
import VueRouter from 'vue-router'
import store from './vuex/store'
import App from './App.vue'
import Vuetify from 'vuetify'
import VeeValidate from 'vee-validate';
import wysiwyg from "vue-wysiwyg";
import RecipeList from './components/RecipeList.vue'
import RecipeSingle from './components/RecipeSingle.vue'
import './stylus/main.styl'Vue.use(VueRouter)
Vue.use(Vuetify)
Vue.use(VeeValidate)
Vue.use(wysiwyg,{
hideModules: { bold: true, table: true, image: true }
})const routes = [
{ name: 'home', path: '/', component: RecipeList },
{ name: 'recipes', path: '/recipes', component: RecipeList },
{ name: 'recipe', path: '/recipe/:id', component: RecipeSingle }
];const router = new VueRouter({
routes
});new Vue({
store,
el: '#app',
router,
render: h => h(App),
beforeMount: function(){
this.$store.dispatch("getRecipes");
}
})
Di beberapa baris pertama saya mengimpor semua paket dan komponen ke dalam aplikasi. Vue.use()
menyuntikkan paket kita ke dalam vue sehingga kita dapat menggunakannya. Vue.use(VueRouter) menyertakan router di aplikasi kami. Yang akan menyediakan navigasi untuk aplikasi.
Rute
const routes = [
{ name: 'home', path: '/', component: RecipeList },
{ name: 'recipes', path: '/recipes', component: RecipeList },
{ name: 'recipe', path: '/recipe/:id', component: RecipeSingle }
];
Ini adalah kemungkinan rute kami. Beranda dan Resep untuk menampilkan daftar resep. Dan /recipe/:id
akan menampilkan resepnya.
Contoh Vue
new Vue({
store,
el: '#app',
router,
render: h => h(App),
beforeMount: function(){
this.$store.dispatch("getRecipes");
}
})
Di sini instance Vue dibuat. el: '#app'
adalah id elemen dari index.html tempat vue akan memasukkan aplikasi. toko adalah toko vuex. Saya akan berbicara tentang vuex nanti. router
sedang menyuntikkan objek router. render: h => h(App)
memberi tahu app untuk menyuntikkan komponen App terlebih dahulu di App.
Atur Aplikasi.vue
\src\App.js
adalah komponen pertama yang akan dirender. Saya menggunakan komponen vuetify untuk UI dan menyediakan stylus untuk css. Anda dapat memeriksa dokumentasi mereka di sini. Blok kode terpenting di sini untuk menyertakan tampilan router dalam template <router-view></router-view>
. Sepotong kode ini akan merender router dan komponen masing-masing berdasarkan rute.
Vuex
Ke depan pertama saya membahas tentang Vuex dan mengapa kita harus menggunakannya. Anda jelas dapat menggunakan status dan alat peraga sederhana untuk aplikasi yang lebih kecil. Tapi untuk aplikasi besar kita harus menggunakan semacam state management seperti redux di React. Vuex dikelola oleh tim vue dan digunakan secara luas. Hari ini kita akan melakukannya. Di bawah ini adalah diagram alur Vuex yang akan saya jelaskan.
Ada empat konsep utama dalam vuex.
- State
- Actions
- Mutations
- Getters
State adalah tempat seluruh state/data aplikasi kita akan disimpan. Jadi bagaimana cara kerjanya? Misalnya, Anda menggunakan toko ini di komponen Anda dan ingin mengubah beberapa nilai status. Anda harus mengirimkan tindakan. Kemudian tindakan akan melakukan mutasi dan itu akan mengubah keadaan. Jadi mengapa diperlukan tindakan, kita dapat mengubah keadaan secara langsung dalam mutasi. Tindakan sangat penting. Kami membutuhkan tindakan ketika kami harus memanggil fungsi async apa pun dan kemudian berhasil mengubah statusnya. Dalam hal API, kami menggunakan permintaan dalam tindakan dan melakukan mutasi untuk mengubah status. Ini sangat sederhana tetapi sangat penting untuk diikuti.
Getter adalah fungsi getter sederhana untuk mendapatkan nilai state dalam komponen. Jika Anda memerlukan satu nilai status dalam komponen yang berbeda, maka lebih baik membuat pengambil untuknya dan menggunakan pengambil dalam komponen tersebut sebagai properti yang dihitung.
Merupakan konvensi untuk membuat folder vuex dan kemudian membuat store.js di dalamnya. Jadi saya melakukan hal yang sama dan membuat src/vuex/store.js. Di bawah ini adalah kode store.js:
import Vue from 'vue'
import Vuex from 'vuex'
import Request from '../common/request'
import _ from 'lodash';Vue.use(Vuex)// the root, initial state object
const state = {
recipes: [
],
status: {
loading: false,
success: false,
error: false
},
categories: [
"Dessert",
"Meal"
],
recipe: {
metadata:{
feature_image: {
},
ingredients:[]
}
},
editForm: false,
editting: false,
pagination: {
page: 1,
limit: 12,
total: 0
}
}// define the possible getters that can be applied to our state
const getters = {
recipes(state){
return state.recipes;
},
recipe(state){
return (keyword) => _.find(state.recipes,['_id', keyword]);
},
recipeModel(state){
return state.recipe;
},
loading(state){
return state.status.loading;
},
editForm(state){
return state.editForm;
},
categories(state){
return state.categories;
},
editting(state){
return state.editting;
},
pagination(state){
return state.pagination;
},
page(state){
return state.pagination.page;
}
}// define the possible mutations that can be applied to our state
const mutations = {
SET_TOTAL(state,payload){
state.pagination.total = Math.ceil(payload / state.pagination.limit);
},
SET_RECIPES(state,payload){
state.recipes = payload;
},
SET_RECIPE(state,payload){
state.recipe = payload;
},
ADD_RECIPE(state,payload){
state.recipes.unshift(payload);
},
EDIT_RECIPE(state,payload){
state.recipes = _.unionBy([payload],state.recipes,'_id');
},
DELETE_RECIPE(state,payload){
_.remove(state.recipes, function (recipe) {
return recipe._id === payload._id
});
},
LOADING(state){
state.status = {
loading: true,
success: false,
error: false
};
},
SUCCESS(state){
state.status = {
loading: false,
success: true,
error: false
};
},
ERROR(state,payload){
state.status = {
loading: false,
success: false,
error: payload
};
},
CLEAR_ERROR(state){
state.status = {
loading: false,
success: false,
error: false
};
},
TOGGLE_EDITFORM(state,payload){
state.editForm = payload;
},
ADD_INGREDIANT_RECIPE(state,payload){
state.recipe.metadata.ingredients.push({
ingredient: payload
});
},
REMOVE_INGREDIANT_RECIPE(state,payload){
state.recipe.metadata.ingredients.splice(payload, 1);
},
SET_RECIPE_IMAGE(state,payload){
state.recipe.metadata.feature_image.url = payload;
},
SET_RECIPE_FILE(state,payload){
state.recipe.metadata.feature_image.file = payload;
},
TOGGLE_EDITTING(state){
state.editting = !state.editting;
},
SET_RECIPE_DEFAULT(state){
state.recipe = {
metadata:{
feature_image: {
},
ingredients:[]
}
};
},
PAGINATE(state,payload){
state.pagination.page = payload;
}
}
// define the possible actions that can be applied to our state
const actions = {
getRecipes(context){
context.commit('LOADING');
Request.getRecipes(context.getters.pagination).then(res => {
context.commit('SET_RECIPES',res.objects.all || []);
context.commit('SET_TOTAL',res.total || 0);
context.commit('SUCCESS');
})
.catch(e => {
context.commit('ERROR',e);
});
},
setRecipe(context,payload){
context.commit('SET_RECIPE',_.cloneDeep(payload));
},
setRecipeDefault(context){
context.commit('SET_RECIPE_DEFAULT');
},
addRecipe(context,payload){
context.commit('LOADING');
Request.addRecipe(payload).then(recipe => {
context.commit('ADD_RECIPE',recipe);
context.commit('SET_RECIPE_DEFAULT');
context.commit('TOGGLE_EDITFORM',false);
context.commit('SUCCESS');
})
.catch(e => {
context.commit('ERROR',e);
});
},
editRecipe(context,payload){
context.commit('LOADING');
Request.editRecipe(payload).then(recipe => {
context.commit('EDIT_RECIPE',recipe);
context.commit('SET_RECIPE_DEFAULT');
context.commit('TOGGLE_EDITTING');
context.commit('TOGGLE_EDITFORM',false);
context.commit('SUCCESS');
})
.catch(e => {
context.commit('ERROR',e);
});
},
deleteRecipe(context,payload){
context.commit('LOADING');
Request.deleteRecipe(payload).then((res) => {
if(res.status == 200){
context.commit('DELETE_RECIPE',payload);
context.commit('SUCCESS');
}
else{
context.commit('ERROR',res);
}
})
.then((e) => {
context.commit('ERROR',e);
});
},
clearError(context){
context.commit('CLEAR_ERROR');
},
setEditForm(context,payload){
context.commit('TOGGLE_EDITFORM',payload);
},
addIngrediantInRecipe(context,payload){
context.commit('ADD_INGREDIANT_RECIPE',payload);
},
removeIngrediantInRecipe(context,payload){
context.commit('REMOVE_INGREDIANT_RECIPE',payload);
},
setRecipeImage(context,payload){
context.commit('SET_RECIPE_IMAGE',payload);
},
setRecipeFile(context,payload){
context.commit('SET_RECIPE_FILE',payload);
},
toggleEditting(context){
context.commit('TOGGLE_EDITTING');
},
paginate(context,payload){
context.commit('PAGINATE',payload);
context.dispatch('getRecipes');
}
}// create the Vuex instance by combining the state and mutations objects
// then export the Vuex store for use by our components
export default new Vuex.Store({
state,
getters,
mutations,
actions
})
Konfigurasi
src/config/config.js
adalah file untuk menyimpan konfigurasi dasar untuk mengakses data bucket cosmicjs menggunakan paket api atau npm mereka.
const config = {
bucket: {
slug: YOUR_BUCKET_SLUG,
read_key: YOUR_BUCKET_READ_KEY,
write_key: YOUR_BUCKET_WRITE_KEY
},
object_type: YOUR_OBJECT_TYPE,
image_folder: IMAGE_FOLDER_SLUG
};export default config;
Commons
Di src/common/
saya membuat tiga file .js untuk menyusun kode saya dan membuatnya dapat diskalakan. file src/common/request.js
akan menggunakan paket src/common/Cosmic.js
dan membuat permintaan ke titik akhir yang disediakan dan mengembalikan Janji. src/common/paramMapping.js
akan membuat objek permintaan jika ditambahkan dan diedit.
Di src/common/Cosmic.js
saya memperluas paket cosmic-js karena tidak ada fungsionalitas deleteMediaand
sort dalam fungsi getObjectsByType
.
Catatan: Saya membuat PR. Saya harap ini akan ditambahkan di luar kotak dalam paket cosmic-js.
Kode tambahan Cosmic.js ada di bawah.
import Cosmic from 'cosmicjs';
var api_url = 'https://api.cosmicjs.com';
var api_version = 'v1';Cosmic.getObjectsByType = function(config, object, callback){
var endpoint = api_url + '/' + api_version + '/' + config.bucket.slug + '/object-type/' + object.type_slug + '?read_key=' + config.bucket.read_key;
if (object.limit) endpoint += '&limit=' + object.limit;
if (object.skip) endpoint += '&skip=' + object.skip;
if (object.locale) endpoint += '&locale=' + object.locale;
if (object.sort) endpoint += '&sort=' + object.sort;
fetch(endpoint)
.then(function(response){
if (response.status >= 400) {
var err = {
"message" : "There was an error with this request."
}
return callback(err, false);
}
return response.json()
})
.then(function(response){
// Constructor
var cosmic = {};
var objects = response.objects;
cosmic.objects = {};
cosmic.objects.all = objects;
cosmic.object = _.map(objects, keyMetafields);
cosmic.object = _.keyBy(cosmic.object, "slug");
cosmic.total = response.total;
return callback(false, cosmic);
});
};Cosmic.deleteMedia = function(config, object, callback){
var endpoint = api_url + '/' + api_version + '/' + config.bucket.slug + '/media/' + object.media_id;
fetch(endpoint, {
method: 'delete',
headers: {
'Content-type': 'application/json'
},
body: JSON.stringify(object)
})
.then(function(response){
if (response.status >= 400) {
var err = {
'message': 'There was an error with this request.'
}
return callback(err, false);
}
return response.json()
})
.then(function(response){
return callback(false, response);
});
};function keyMetafields(object){
var metafields = object.metafields;
if(metafields){
object.metafield = _.keyBy(metafields, 'key');
}
return object;
}export default Cosmic;
Komponen
Dalam aplikasi ini saya membuat komponen berikut.
- RecipeList.vue
- RecipeSingle.vue
- RecipeForm.vue
RecipeList.vue
RecipeList.vue adalah komponen utama. Ini akan menampilkan daftar resep dalam tampilan kartu. Sekali lagi saya menggunakan komponen vuetify. Ini menyediakan fungsi tambah, edit, hapus dan pagination juga. Untuk mengedit & menambahkan saya telah menggunakan Modal/Dialog untuk membuka formulir. Itu ada di komponen RecipeForm.vue
.
import {mapActions,mapGetters} from 'vuex';
Ini akan memetakan pengambil toko vuex ke properti komputasi Vue dan memetakan tindakan ke metode.
<template>
<div>
<v-container fill-height class="noRecipes" v-if="recipes.length == 0 && !loading">
<v-layout row wrap align-center>
<v-flex class="text-xs-center">
<h4>There is no recipe please add one!</h4>
<v-btn light large class="amber" @click="openAddForm" :disabled="loading">Add Recipe</v-btn>
</v-flex>
</v-layout>
</v-container>
<v-container grid-list-lg text-xs-center>
<v-layout row wrap v-if="recipes.length > 0">
<v-flex md4 sm6 xs12 v-for="(recipe, index) in recipes" :key="index">
<v-card>
<router-link :to="{name:'recipe', params:{id:recipe._id}}">
<v-card-media :src="recipe.metadata.feature_image.url.replace(/ /g,'%20')" height="200px">
</v-card-media>
</router-link>
<v-card-title primary-title>
<div>
<h3 class="headline mb-0">
<router-link :to="{name:'recipe', params:{id:recipe._id}}">
{{recipe.title}}
</router-link>
</h3>
<div>{{recipe.metadata.author}}</div>
</div>
</v-card-title>
<v-card-actions class="white">
<v-spacer></v-spacer>
<v-btn icon :disabled="loading" @click="openEditForm(recipe)">
<v-icon class="blue--text">edit</v-icon>
</v-btn>
<v-btn icon :disabled="loading" @click="deleteRecipe(recipe)">
<v-icon class="red--text">delete</v-icon>
</v-btn>
<v-spacer></v-spacer>
</v-card-actions>
</v-card>
</v-flex>
<v-btn fixed light fab bottom right class="amber darken-2" @click="openAddForm" :disabled="loading" >
<v-icon>add</v-icon>
</v-btn>
</v-layout>
<div class="text-xs-center recipes-pagination" v-if="recipes.length > 0">
<v-pagination :disabled="loading" :length="pagination.total" v-model="pagination.page" :total-visible="5" circle></v-pagination>
</div>
<recipe-form></recipe-form>
<v-snackbar :timeout="2000" success v-model="success">
Request done successfully!
</v-snackbar>
<v-snackbar :timeout="3000" error v-model="error">
There was an error during request!
</v-snackbar>
</v-container>
</div>
</template><script>
import {mapActions,mapGetters} from 'vuex';
import RecipeForm from './RecipeForm.vue';
export default {
components:{
'recipe-form': RecipeForm
},
computed: {
success: {
get: function(){
return this.$store.state.status.success;
},
set: function(value){
this.$store.dispatch('clearError');
}
},
error: {
get: function(){
return this.$store.state.status.error;
},
set: function(value){
this.$store.dispatch('clearError');
}
},
...mapGetters([
'recipes','pagination','loading','editForm','page'
])
},
watch: {
page: 'getRecipes'
},
methods:{
openAddForm(){
this.$store.dispatch('setRecipeDefault');
this.$store.dispatch('setEditForm',true);
},
openEditForm(recipe){
this.$store.dispatch('setRecipe',recipe);
this.$store.dispatch('toggleEditting');
this.$store.dispatch('setEditForm',true);
},
deleteRecipe(recipe){
this.$store.dispatch('deleteRecipe',recipe);
},
getRecipes(){
this.$store.dispatch('getRecipes');
}
}
}
</script><style lang="stylus" scoped>
#keep
.card__title
display: block .headline
a
color: rgba(0,0,0,0.87)
text-decoration: none
.v-spinner
text-align: center
.noRecipes
position: absolute
right: 0
left: 0
text-align: center
height: auto
top: 60px
bottom: 0
.recipes-pagination
margin-top: 80px
.pagination >>> li > a.pagination__item--active
background: #FFC107
color: #000
font-weight: bold
</style>
RecipeForm.vue
RecipeForm.vue digunakan untuk mengirimkan formulir dan melakukan validasi dasar.
<template>
<v-layout row justify-center>
<v-dialog v-model="editForm" scrollable persistent width="50vw">
<v-card>
<v-card-title>
<span class="headline">{{ editting ? 'Edit ' : 'Add ' }}Recipe</span>
</v-card-title>
<v-divider></v-divider>
<v-card-text style="height: 70vh;">
<v-container grid-list-md>
<v-layout wrap>
<v-flex xs12>
<v-text-field v-model="recipeModel.title" label="Title" :error-messages="errors.collect('title')" v-validate="'required'" data-vv-name="title" required></v-text-field>
</v-flex>
<v-flex xs12>
<div class="input-group input-group--dirty">
<label>Content</label>
<wysiwyg v-model="recipeModel.content" />
</div>
</v-flex>
<v-flex xs12 class="mb-2">
<div class="input-group input-group--dirty">
<label class="ingredients_list_label">Ingredients</label>
<ul class="ingredients_list">
<li v-for="(item,index) in recipeModel.metadata.ingredients" :key="index">
{{item.ingredient}}
<v-btn fab dark small error @click="removeIngrediant(index)" class="btn_remove_ingredient">
<v-icon dark>remove</v-icon>
</v-btn>
</li>
</ul>
</div>
</v-flex>
<v-flex xs10 class="mb-3">
<v-text-field ref="addIngredientRef" label="Ingredient"></v-text-field>
</v-flex>
<v-flex xs2 class="mb-3">
<v-btn warning fab small dark @click="addIngrediant($refs.addIngredientRef)">
<v-icon>add</v-icon>
</v-btn>
</v-flex>
<v-flex xs12>
<v-text-field v-model="recipeModel.metadata.author" label="Author" :error-messages="errors.collect('author')" v-validate="'required'" data-vv-name="author" required></v-text-field>
</v-flex>
<v-flex xs12>
<v-text-field v-model="recipeModel.metadata.preparation_time" label="Preparation Time (in minutes)" :error-messages="errors.collect('preparation')" v-validate="'required|numeric'" data-vv-name="preparation" required></v-text-field>
</v-flex>
<v-flex xs12>
<v-text-field v-model="recipeModel.metadata.cook_time" label="Cook Time (in minutes)" :error-messages="errors.collect('cook')" v-validate="'required|numeric'" data-vv-name="cook" required></v-text-field>
</v-flex>
<v-flex xs12>
<v-text-field v-model="recipeModel.metadata.servings" label="Servings (person)" :error-messages="errors.collect('servings')" v-validate="'required|numeric'" data-vv-name="servings" required></v-text-field>
</v-flex>
<v-flex xs12>
<v-select
v-bind:items="categories"
v-model="recipeModel.metadata.category"
label="Choose Category:"
:error-messages="errors.collect('category')"
v-validate="'required'"
data-vv-name="category"
required
></v-select>
</v-flex>
<v-flex xs12>
<v-text-field v-model="recipeModel.metadata.youtube_id" label="Youtube ID" :error-messages="errors.collect('youtube')" v-validate="'required'" data-vv-name="youtube" required></v-text-field>
</v-flex>
<v-flex xs12>
<img class="upload_image" :src="recipeModel.metadata.feature_image.url.replace(/ /g,'%20')" v-if="!!recipeModel.metadata.feature_image.url" />
<form enctype="multipart/form-data" novalidate>
<input type="file" @change="onFileChange" accept="image/*" data-vv-name="image" v-validate="'required|mimes:image/*'" required />
<div class="input-group fileUploadError">
<div class="input-group__error" v-show="errors.has('image') && !editting">
{{ errors.first('image') }}
</div>
</div>
</form>
</v-flex>
</v-layout>
</v-container>
<small>*indicates required field</small>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn error dark @click="closeDialog" :disabled="loading">Close</v-btn>
<v-btn :loading="loading" :disabled="loading" primary dark @click="saveRecipe(recipeModel)">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-layout>
</template><script>
import { mapActions, mapGetters } from 'vuex';
export default {
computed: {
...mapGetters([
'editForm', 'recipeModel','categories','editting','loading'
])
},
methods: {
closeDialog() {
this.$validator.reset();
this.$store.dispatch('setRecipeDefault');
this.$store.dispatch('setEditForm', false);
},
saveRecipe(recipe) {
this.$validator.validateAll();
if(this.$store.state.editting){
if( (!this.errors.any()) || (this.errors.count() == 1 && this.errors.has('image'))) {
this.$store.dispatch('editRecipe', recipe);
}
}
else
{
if(!this.errors.any()){
this.$store.dispatch('addRecipe', recipe);
}
}
},
addIngrediant(item){
if(item.$refs.input.value){
this.$store.dispatch('addIngrediantInRecipe', item.$refs.input.value);
}
},
removeIngrediant(id){
this.$store.dispatch('removeIngrediantInRecipe', id);
},
onFileChange(e){
var files = e.target.files || e.dataTransfer.files;
if (!files.length){
this.$store.dispatch('setRecipeImage', '');
this.$store.dispatch('setRecipeFile', '');
return;
}
var image = new Image();
var reader = new FileReader(); reader.onload = (e) => {
this.$store.dispatch('setRecipeImage', e.target.result);
this.$store.dispatch('setRecipeFile', files[0]);
};
reader.readAsDataURL(files[0]);
}
}
}
</script>
<style lang="stylus" scoped>
.ingredients_list_label
width: 100%
.ingredients_list
width: 100%
li
position: relative
padding: 2px 40px 2px 0
.btn_remove_ingredient
position: absolute
right: 10px
width: 20px
height: 20px
margin: 0 !important
i.icon
font-size: 14px
.upload_image
width: 200px
.fileUploadError
padding: 2px 0 0
.application .theme--dark.btn.primary.btn--disabled:not(.btn--icon):not(.btn--flat)
background-color: #1976d2 !important
border-color: #1976d2 !important
.application .theme--dark.btn.error.btn--disabled:not(.btn--icon):not(.btn--flat)
background-color: #ff5252 !important
border-color: #ff5252 !important
</style>
RecipeSingle.vue
RecipeSingle.vue merender resep tunggal.
<template>
<div id="recipe_single">
<div v-if="recipe">
<v-breadcrumbs icons divider="chevron_right">
<v-breadcrumbs-item replace :to="{name: 'recipes'}">
Home
</v-breadcrumbs-item>
<v-breadcrumbs-item disabled>
{{ recipe.title }}
</v-breadcrumbs-item>
<v-spacer></v-spacer>
<v-btn primary light class="amber" replace :to="{name: 'recipes'}">Back</v-btn>
</v-breadcrumbs>
</div>
<v-container fluid grid-list-lg>
<v-layout row wrap>
<v-flex md10 xs12 offset-md1 v-if="recipe">
<v-layout row wrap>
<v-flex xs12 sm6 md4>
<v-card>
<v-card-media :src="recipe.metadata.feature_image.url.replace(/ /g,'%20')" height="300px">
</v-card-media>
</v-card>
</v-flex>
<v-flex xs12 sm6 md8 class="recipe_info">
<h1 class="headline mb-0">
{{recipe.title}}
</h1>
<ul class="recipe_meta">
<li><p><strong>PUBLISHED BY</strong>{{recipe.metadata.author}}</p></li>
<li><p><strong>PREPARATION</strong>{{recipe.metadata.preparation_time}} {{ recipe.metadata.preparation_time > 1 ? 'minutes' : 'minute' }}</p></li>
<li><p><strong>COOK TIME</strong>{{recipe.metadata.cook_time}} {{ recipe.metadata.cook_time > 1 ? 'minutes' : 'minute' }}</p></li>
<li><p><strong>SERVINGS</strong>{{recipe.metadata.servings}} {{ recipe.metadata.servings > 1 ? 'persons' : 'person' }}</p></li>
<li><p><strong>Category</strong><v-chip class="amber dark--text">{{recipe.metadata.category}}</v-chip></p></li>
</ul>
</v-flex>
</v-layout>
<p class="mt-3"></p>
<v-layout row wrap class="recipe_content_wrap">
<v-flex xs12 md8 offset-md2>
<div class="video-responsive">
<iframe width="560" height="315" :src="'https://www.youtube.com/embed/'+recipe.metadata.youtube_id" frameborder="0" allowfullscreen></iframe>
</div>
</v-flex>
</v-layout>
<p class="mt-5"></p>
<v-card class="white">
<v-layout row wrap class="recipe_content_wrap">
<v-flex xs12 md6 order-md2>
<h1 class="headline mb-0">
Ingredients:
</h1>
<v-list avatar>
<v-list-tile v-for="(item, index) in recipe.metadata.ingredients" :key="index">
<v-list-tile-avatar>
<v-icon class="amber--text text--darken-2">fa-circle</v-icon>
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title>{{item.ingredient}}</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list>
</v-flex>
<v-flex xs12 md6>
<h1 class="headline mb-0">
Preparation:
</h1>
<p v-html="recipe.content" class="recipe_content"></p>
</v-flex>
</v-layout>
</v-card>
</v-flex>
</v-layout>
</v-container>
</div>
</template><script>
import {mapActions,mapGetters} from 'vuex'
export default {
computed: {
recipe(){
return this.$store.getters.recipe(this.$route.params.id)
}
},
methods:{ }
}
</script><style lang="stylus">
#recipe_single
.headline
font-size: 32px !important
padding-top: 15px
.recipe_meta
padding-top: 30px
padding-left: 15px
list-style: none
strong
margin-right: 10px
.recipe_content_wrap
padding: 10px 20px
.recipe_content
padding-top: 15px
padding-left: 10px
.list
> li
height: 30px
.icon
font-size: 16px
.video-responsive
overflow:hidden
padding-bottom:56.25%
position:relative
height:0
.video-responsive iframe
left:0
top:0
height:100%
width:100%
position:absolute
.breadcrumbs
li:first-child
a
color: #FFC107
li:nth-child(2):after
content: ''
</style>
Kesimpulan
Jadi, ini adalah aplikasi di mana setiap skenario Cosmic RESTful API menggunakan paket cosmic npm vue & vuex tercakup. Saya harap Anda menyukai tutorialnya.