Создание универсального приложения с Nuxt.js и Django

Spread the love

Введение

Появление современных библиотек JavaScript, таких как React.js и Vue.js, трансформировало front-end веб-разработку в лучшую сторону. Эти библиотеки поставляются с богатым набором функциональности, позволяя быстро создавать одностраничные приложения SPA, которые в основном представляют собой динамическую загрузку и отображение содержимого веб-страниц без полной перезагрузки браузера.

Концепция большинства одностраничных приложений – рендеринг на стороне клиента. При рендеринге на стороне клиента большая часть контента отображается в браузере с использованием JavaScript; при загрузке страницы содержимое не загружается изначально до тех пор, пока JavaScript не будет полностью загружен, и далее отображает остальную часть сайта.

Рендеринг на стороне клиента является относительно новой концепцией, и с его использованием связаны некоторые компромиссы. Основным отрицательным моментом является то, что, поскольку контент не отображается, пока страница полностью не загрузится с использованием JavaScript, то SEO для веб-сайта сильно усложняется. Так как вряд ли поисковых системы смогут увидеть какие-либо данные при сканирование.

С другой стороны, рендеринг на стороне сервера – это традиционный способ отображение HTML-страниц в браузере. В традиционных приложениях для рендеринга на стороне сервера использовался серверный язык программирования, такой как PHP. Когда браузер запрашивает веб-страницу, удаленный сервер добавляет динамический контент и отображает заполненную HTML-страницу.

Но при рендеринге на стороне сервера основной недостаток, то что при каждом запросе к серверу, страница будет полностью перезагружаться.

В этот момент можно предположить: «Что если мы могли бы изначально загрузить веб-страницу с помощью решения SSR (Server-Side Rendering – рендеринга на стороне сервера), а затем использовать соответствующую архитектуру для обработки дальнейшей динамической маршрутизации и извлечения только необходимых данных и динамического отображения?»

Отличная идея! К тому же уже существуют JavaScript-фреймворки, которые реализуют подобное решение, и созданные таким образом приложения называются Universal Applications (универсальными приложениями).

Правильно сказать: универсальное приложение = SSR + SPA.

Далее мы создадим одно из таких универсальных приложений. Назовем его Recipe, и будет использовать Nuxt.js в качестве javascript фреймворка.

Nuxt.js – это не только простой фреймворк но еще и высокоуровневая инфраструктура для разработки универсальных приложений Vue.js. Его создание было вдохновлено фреймворком на базе React.js Next.js. Он помогает абстрагироваться от трудностей конфигурацией и распределение клиентского кода, возникающих при настройке приложений Vue.js.

В этой статье мы расскажем, как создать универсальное приложение, используя Django и Nuxt.js. Django будет обрабатывать бэкэнд-операции и предоставлять API-интерфейс, используя (DRF) Django Rest Framework, а Nuxt.js создаст фронтенд.

Окончательная версия приложения будет выглядеть как так:

Исходный код приложения будет доступен здесь, на GitHub.

Базовые требования

Чтобы следовать этому руководству, на вашем компьютере должно быть установлено следующее:

  1. Python3.
  2. Pip.
  3. Npm.

Эта статья так же предполагает, что у читателя есть следующее навыки:

  1. Базовые знания Django и Django Rest Framework.
  2. Базовые знания Vue.js.

Итак давайте начнем!

Настройка Backend

В этом разделе мы настроим бэкэнд и создадим все папки, которые нам будут нужны для запуска, поэтому запустите новый экземпляр терминала и создайте каталог проекта, выполнив следующую команду:

$ mkdir recipes_app

Далее перейдите в каталог:

cd recipes_app

Далее нам понадобиться Pipenv

Pipenv – это инструмент, цель которого – привнести лучшее из всех упаковочных миров в мир Python. Он объединяет Pipfile, pip и virtualenv в одну команду.


Более подробно о pipenv, так же можно почитать здесь

Теперь мы установим Pipenv с помощью Pip и активируем новую виртуальную среду:

$ pip install pipenv
$ pipenv shell

Примечание. Вам можете пропустить первую команду, если на вашем компьютере уже установлена Pipenv.

Далее установим Django и другие зависимости, используя Pipenv:

(recipes_app) $ pipenv install django django-rest-framework django-cors-headers

Теперь мы создадим новый проект Django под названием api и приложение Django под названием core:

(recipes_app) $ django-admin startproject api
(recipes_app) $ cd api
(recipes_app) $ python manage.py startapp core

Далее зарегистрируем основное приложение вместе с rest_framework и cors-headers, чтобы проект Django распознал его. Откройте файл api/settings.py и внесите в него следующие изменения:

# api/settings.py

# Application definition
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework', # add this
    'corsheaders', # add this
    'core' # add this 
  ] 

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware', # add this
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

 # add this block below MIDDLEWARE
CORS_ORIGIN_ALLOW_ALL = True

# add the following just below STATIC_URL
MEDIA_URL = '/media/' # add this
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # add this

Примечание: мы добавили CORS_ORIGIN_ALLOW_ALL = True, что бы предотвратить ошибки CORS. Мы также добавили MEDIA_URL и MEDIA_ROOT, потому что они нам понадобятся для отображения изображений в приложении.

Определение модели Recipe

Давайте создадим модель, чтобы определить, как элементы Recipe должны будут храниться в базе данных, для этого откроем файл core/models.py и полностью заменим его фрагментом ниже:

# core/models.py

from django.db import models
# Create your models here.

class Recipe(models.Model):
    DIFFICULTY_LEVELS = (
        ('Easy', 'Easy'),
        ('Medium', 'Medium'),
        ('Hard', 'Hard'),
    )
    name = models.CharField(max_length=120)
    ingredients = models.CharField(max_length=400)
    picture = models.FileField()
    difficulty = models.CharField(choices=DIFFICULTY_LEVELS, max_length=10)
    prep_time = models.PositiveIntegerField()
    prep_guide = models.TextField()

    def __str_(self):
        return "Recipe for {}".format(self.name)

Приведенный выше фрагмент кода описывает шесть свойств модели Recipe:

  • Name
  • Ingredients
  • Picture
  • Difficulty
  • Prep_time
  • Prep_guide

Создание сериализаторов для модели Recipe

Нам так же нужны сериализаторы для преобразования экземпляров модели в JSON, чтобы интерфейс мог легко работать с полученными данными. Для этого мы создадим файл core/serializers.py и обновим его следующим образом:

# core/serializers.py

from rest_framework import serializers
from .models import Recipe


class RecipeSerializer(serializers.ModelSerializer):

    class Meta:
        model = Recipe
        fields = ("id", "name", "ingredients", "picture", "difficulty", "prep_time", "prep_guide")

В приведенном выше фрагменте кода мы указали модель для работы и поля, которые мы хотим преобразовать в JSON.

Настройка панели администратора

Django предоставляет нам интерфейс администратора из коробки; Интерфейс позволит легко тестировать операции CRUD только что созданной нами модели Recipe, но сначала мы сделаем небольшую настройку.

Откройте файл core/admin.py и полностью замените его фрагментом ниже:

# core/admin.py

from django.contrib import admin
from .models import Recipe  # add this
# Register your models here.

admin.site.register(Recipe) # add this

Создание Views

Далее давайте создадим класс RecipeViewSet в файле core/views.py:

# core/views.py

from rest_framework import viewsets
from .serializers import RecipeSerializer
from .models import Recipe


class RecipeViewSet(viewsets.ModelViewSet):
    serializer_class = RecipeSerializer
    queryset = Recipe.objects.all()

Примечание: viewsets.ModelViewSet предоставляет методы для обработки операций CRUD по умолчанию. Нам просто нужно указать класс сериализатора и набор запросов.

Настройка URL

Далее перейдите к файлу api/urls.py и полностью замените его приведенным ниже кодом. Этот код указывает путь URL для API:

# api/urls.py
from django.contrib import admin
from django.urls import path, include        # add this
from django.conf import settings             # add this
from django.conf.urls.static import static   # add this

urlpatterns = [
    path('admin/', admin.site.urls),
    path("api/", include('core.urls'))       # add this
]

# add this
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Теперь создайте файл urls.py в директории core и вставьте в него следующий фрагмент:

# core/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import RecipeViewSet

router = DefaultRouter()
router.register(r'recipes', RecipeViewSet)

urlpatterns = [
    path("", include(router.urls))
]

В приведенном выше коде класс router генерирует следующие шаблоны URL:

  • /recipes/ – Операции создания (Create) и чтения (Read) рецептов.
  • /recipes/{id} – Операции чтения (Read), обновления (Update) и удаления (Delete) для выбранного рецепта.

Запуск Миграции

Поскольку мы недавно создали модель Recipe и определили ее структуру, нам нужно создать файл миграции и применить изменения в модели к базе данных, поэтому давайте выполним следующие команды:

(recipes_app) $ python manage.py makemigrations
(recipes_app) $ python manage.py migrate

Теперь мы создадим учетную запись суперпользователя для доступа к интерфейсу администратора:

(recipes_app) $ python manage.py createsuperuser

Вам будет предложено ввести имя пользователя, адрес электронной почты и пароль для суперпользователя. Обязательно запомните то что введете, потому что эти данные понадобятся вам для входа на панель администратора в ближайшее время.

Ура! Вот и все настройки, которые необходимо выполнить на бэкэнде. Теперь мы можем протестировать созданные нами API, для этого давайте запустим сервер Django:

(recipes_app) $ python manage.py runserver

После запуска сервера перейдите по адресу http://localhost:8000/api/recipes/ и убедитесь, что все работает:

Мы так же можем создать новый элемент Recipe, используя этот интерфейс:

Мы также можем выполнять операции DELETE, PUT и PATCH для определенных рецептов, используя их первичные ключи id. Для этого мы зайдите на адрес с такой структурой /api/recipe/{id}. Можете попробовать с этим адресом – http://localhost:8000/api/recipes/1:

Теперь это все для серверной части приложения, сейчас мы можем перейти к фронтенду.

Настройка фронтенда

В этом разделе руководства мы создадим интерфейс приложения. Мы разместим папку для кода интерфейса в корне каталога recipes_app. Итак, перейдите из каталога api (или откройте новый терминал, чтобы запустить его вместе с предыдущим), прежде чем запускать команды в этом разделе.

Давайте создадим nuxt приложение под названием client с помощью этой команды:

$ npx create-nuxt-app client

После завершения установки create-nuxt-app задаст несколько вопросов о дополнительных инструментах, которые необходимо будет ответить. Мы ответили на них следующим образом:

  • Enter a project name or just hit enter for default
  • Enter a project description or just hit enter for default
  • Select none for custom server framework
  • Select PWA support for features to install
  • Select bootstrap for UI framework
  • Select none for test framework
  • Select Universal for rendering mode
  • Enter an author name or just hit enter for default
  • Select npm for package manager

После этого запустится установка зависимостей с помощью выбранного менеджера пакетов и, наконец, вы увидите экран, подобный следующему:

Далее введите следующие команды, чтобы запустить приложение в режиме разработки:

$ cd client
$ npm run dev

После запуска сервера разработки перейдите по адресу http://localhost:3000:

Потрясающие! Теперь давайте взглянем на структуру каталогов папки client:

├── client
  ├── assets/
  ├── components/
  ├── layouts/
  ├── middleware/
  ├── node_modules/
  ├── pages/
  ├── plugins/
  ├── static/
  └── store/

Вот описание того, для чего нужны эти каталоги:

  • Assets – Содержит не компилируемые файлы, такие как изображения, файлы CSS, SASS и JavaScript.
  • Components – Содержит компоненты Vue.js.
  • Layouts – Содержит макеты приложения; Макеты используются для изменения внешнего вида страницы и могут использоваться для нескольких страниц.
  • Middleware – Содержит Middleware ПО приложения; Middleware (Промежуточное программное обеспечение) – это пользовательские функции, которые выполняются до отображения страницы.
  • Pages – Содержит вьюхи и маршруты приложения. Nuxt.js читает все файлы .vue в этом каталоге и использует эту информацию для создания маршрутизатора приложения.
  • Plugins -Содержит плагины JavaScript, которые нужно запустить до создания корневого приложения Vue.js.
  • Static – Содержит статические файлы (файлы, которые вряд ли могут измениться), и все эти файлы сопоставлены с корнем приложения, которым является /.
  • Store – Содержит файлы хранилища, если мы собираемся использовать Vuex с Nuxt.js.

В папке клиента также есть файл nuxt.config.js, который содержит пользовательскую конфигурацию приложения Nuxt.js. Прежде чем продолжить, загрузите этот zip файл, распакуйте его и поместите в папку images/ в директории static/.

Структура страниц

Помните, что мы говорили, что Nuxt.js читает все файлы .vue в каталоге pages/ и использует эту информацию для создания маршрутизатора приложения. В этом разделе мы добавим новые файлы .vue в каталог pages/, чтобы в нашем приложении было пять страниц:

  • домашняя страница
  • Страница всех рецептов
  • Страница просмотра одного рецепта
  • Страница редактирования одного рецепта
  • Страница добавления нового рецепта

Давайте добавим следующие файлы и папки .vue в каталоге pages/, чтобы у нас была точно такая структура:

├── pages/
   ├── recipes/
     ├── _id/
       └── edit.vue
       └── index.vue
     └── add.vue
     └── index.vue
  └── index.vue

Структура файла выше создаст следующие маршруты:

  • / → обрабатывается pages/index.vue
  • /recipes/add → обрабатывается pages/recipes/add.vue
  • /recipes/ → обрабатывается pages/recipes/index.vue
  • /recipes/{id}/ → обрабатывается pages/recipes/_id/index.vue
  • /recipes/{id}/edit → обрабатывается pages/recipes/_id/edit.vue

Примечание. Файл или каталог .vue с префиксом подчеркивания создаст динамический маршрут. Это полезно в нашем приложении, поскольку позволяет легко отображать различные рецепты на основе их идентификаторов, например, recipes/1/, recipes/2/ и т. д.

Создание домашней страницы

В Nuxt.js макеты очень помогают, когда вы хотите изменить внешний вид своего приложения. Теперь каждый экземпляр приложения Nuxt.js поставляется с макетом по умолчанию, и нам надо будет удалить все стили по умолчанию, чтобы они не мешали нашему приложению.

Откройте файл layouts/default.vue и замените его следующим фрагментом ниже:

<template>
  <div>
    <nuxt/>
  </div>
</template>

<style>
</style>

Далее обновим файл pages/index.vue следующим кодом:

<template>
  <header>
    <div class="text-box">
      <h1>La Recipes 😋</h1>
      <p class="mt-3">Recipes for the meals we love ❤️ ️</p>
      <nuxt-link class="btn btn-outline btn-large btn-info" to="/recipes">
        View Recipes <span class="ml-2">→</span>
      </nuxt-link>
    </div>
  </header>
</template>
<script>
export default {
  head() {
    return {
      title: "Home page"
    };
  },
};
</script>
<style>
header {
  min-height: 100vh;
  background-image: linear-gradient(
      to right,
      rgba(0, 0, 0, 0.9),
      rgba(0, 0, 0, 0.4)
    ),
    url("/images/banner.jpg");
  background-position: center;
  background-size: cover;
  position: relative;
}
.text-box {
  position: absolute;
  top: 50%;
  left: 10%;
  transform: translateY(-50%);
  color: #fff;
}
.text-box h1 {
  font-family: cursive;
  font-size: 5rem;
}
.text-box p {
  font-size: 2rem;
  font-weight: lighter;
}
</style>

В приведенном выше коде <nuxt-link> является компонентом Nuxt.js, который можно использовать для перемещения между страницами. Он очень похож на компонент <router-link> от Vue Router.

Далее давайте запустим фронтендовский сервер разработки (если он еще не запущен), и посетим http://localhost:3000/ , должно выглядеть как то так:

npm run dev

Каждая страница в этом приложении будет компонентом Vue, а Nuxt.js предоставляет специальные атрибуты и функции, которые упростят разработку приложения. Вы можете найти документацию по всем этим специальным атрибутам здесь.

Для этого урока мы будем использовать две из этих функций:

  • head() – Этот метод используется для установки определенных <meta> тегов для текущей страницы.
  • asyncData() – Этот метод используется для извлечения данных до загрузки компонента страницы. Возвращенный объект затем объединяется с данными компонента страницы. Мы будем использовать его позже в этом уроке.

Создание страницы списка рецептов

Далее давайте создадим компонент Vue.js с именем RecipeCard.vue в каталоге components/ со следующим содержимым:

<template>
  <div class="card recipe-card">
    <img :src="recipe.picture" class="card-img-top" >
    <div class="card-body">
      <h5 class="card-title">{{ recipe.name }}</h5>
      <p class="card-text">
        <strong>Ingredients:</strong> {{ recipe.ingredients }}
      </p>
      <div class="action-buttons">
        <nuxt-link :to="`/recipes/${recipe.id}/`" class="btn btn-sm btn-success"> View </nuxt-link>
        <nuxt-link :to="`/recipes/${recipe.id}/edit/`" class="btn btn-sm btn-primary"> Edit </nuxt-link>
        <button @click="onDelete(recipe.id)"  class="btn btn-sm btn-danger">Delete</button>
      </div>
    </div>
  </div>
</template>
<script>
export default {
    props: ["recipe", "onDelete"]
};
</script>
<style>
.recipe-card {
    box-shadow: 0 1rem 1.5rem rgba(0,0,0,.6);
}
</style>

Компонент выше принимает два атрибута:

  1. Объект recipe, который содержит информацию о конкретном рецепте.
  2. Метод onDelete, который будет запускаться всякий раз, когда пользователь нажимает кнопку, чтобы удалить рецепт.

Затем откройте pages/recipes/index.vue и обновите его с помощью фрагмента ниже:

<template>
  <main class="container mt-5">
    <div class="row">
      <div class="col-12 text-right mb-4">
        <div class="d-flex justify-content-between">
          <h3>La Recipes</h3>
          <nuxt-link to="/recipes/add" class="btn btn-info">Add Recipe</nuxt-link>
        </div>
      </div>
      <template v-for="recipe in recipes">
        <div :key="recipe.id" class="col-lg-3 col-md-4 col-sm-6 mb-4">
          <recipe-card :onDelete="deleteRecipe" :recipe="recipe"></recipe-card>
        </div>
      </template>
    </div>
  </main>
</template>
<script>
import RecipeCard from "~/components/RecipeCard.vue";

const sampleData = [
  {
    id: 1,
    name: "Jollof Rice",
    picture: "/images/food-1.jpeg",
    ingredients: "Beef, Tomato, Spinach",
    difficulty: "easy",
    prep_time: 15,
    prep_guide:
      "Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis, porro. Dignissimos ducimus ratione totam fugit officiis blanditiis exercitationem, nisi vero architecto quibusdam impedit, earum "
  },
  {
    id: 2,
    name: "Macaroni",
    picture: "/images/food-2.jpeg",
    ingredients: "Beef, Tomato, Spinach",
    difficulty: "easy",
    prep_time: 15,
    prep_guide:
      "Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis, porro. Dignissimos ducimus ratione totam fugit officiis blanditiis exercitationem, nisi vero architecto quibusdam impedit, earum "
  },
  {
    id: 3,
    name: "Fried Rice",
    picture: "/images/banner.jpg",
    ingredients: "Beef, Tomato, Spinach",
    difficulty: "easy",
    prep_time: 15,
    prep_guide:
      "Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis, porro. Dignissimos ducimus ratione totam fugit officiis blanditiis exercitationem, nisi vero architecto quibusdam impedit, earum "
  }
];

export default {
  head() {
    return {
      title: "Recipes list"
    };
  },
  components: {
    RecipeCard
  },
  asyncData(context) {
    let data = sampleData;
    return {
      recipes: data
    };
  },
  data() {
    return {
      recipes: []
    };
  },
  methods: {
    deleteRecipe(recipe_id) {
      console.log(deleted `${recipe.id}`) 
    }
  }
};
</script>
<style scoped>
</style>

Далее запустите фронтендовский сервер разработки (если он еще не запущен), перейдите по адресу http://localhost:3000/recipes . Вы должны увидеть следующую страницу со списком рецептов:

npm run dev

На изображении выше мы видим, что отображаются три карточки рецептов, хотя у нас массив рецептов изначально пустой. Это объясняется тем, что метод asyncData выполняется еще до загрузки страницы и возвращает объект, который обновляет данные компонента.

Теперь все, что нам нужно сделать, – это изменить метод asyncData, так чтобы сделать запрос API к бэкэнду Django и обновить данные компонента.

Прежде чем мы это сделаем, мы должны установить и настроить Axios:

npm install -s @nuxtjs/axios

После установки Axios откройте файл nuxt.config.js и обновите его соответствующим образом:

// client/nuxt.config.js

  /_
  ** Nuxt.js modules
  _/
  modules: [,
    // Doc: https://bootstrap-vue.js.org/docs/
    'bootstrap-vue/nuxt',
    '@nuxtjs/axios' // add this
  ],

  // add this Axios object
  axios: {
    baseURL: "http://localhost:8000/api"
  },

Теперь откройте файл pages/recipes/index.vue и замените в нем раздел <script>

[...]

<script>
import RecipeCard from "~/components/RecipeCard.vue";

export default {
  head() {
    return {
      title: "Recipes list"
    };
  },
  components: {
    RecipeCard
  },
  async asyncData({ $axios, params }) {
    try {
      let recipes = await $axios.$get(`/recipes/`);
      return { recipes };
    } catch (e) {
      return { recipes: [] };
    }
  },
  data() {
    return {
      recipes: []
    };
  },
  methods: {
    async deleteRecipe(recipe_id) {
      try {
        await this.$axios.$delete(`/recipes/${recipe_id}/`); // delete recipe
        let newRecipes = await this.$axios.$get("/recipes/"); // get new list of recipes
        this.recipes = newRecipes; // update list of recipes
      } catch (e) {
        console.log(e);
      }
    }
  }
};
</script>

[...]

В приведенном выше коде asyncData() получает объект с именем context, который мы деструктурируем, чтобы получить $axios. Вы можете проверить все атрибуты контекста здесь.

Примечание: мы заключаем asyncData() в блок try …, потому что мы хотим предотвратить ошибку, которая может возникнет, если внутренний сервер не будет работает и axios не сможет получить данные. Всякий раз, когда это происходит, значение переменной recipes будет пустой массив.

Эта строка кода — let recipes = await $axios.$get("/recipes/") — это более короткая версия:

let response = await $axios.get("/recipes")
let recipes = response.data

Метод deleteRecipe() удаляет выбранные рецепт.

Теперь проверьте что запущен сервер разработки бекенда, и можно будет убедится, что карты рецептов теперь заполняются данными из бэкэнда Django.

Зайдите на http://localhost:3000/recipes, должно быть что то типа такого:

npm run dev

Вы также можете попробовать удалить выбранные рецепты и посмотреть, как обновляется список соответствующим образом.

Добавление новых рецептов

Как мы уже обсуждали, мы хотим иметь возможность добавлять новые рецепты из внешнего интерфейса приложения, поэтому откройте файл pages/recipes/add.vue и обновите его следующим фрагментом:

<template>
  <main class="container my-5">
    <div class="row">
      <div class="col-12 text-center my-3">
        <h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2>
      </div>
      <div class="col-md-6 mb-4">
        <img
          v-if="preview"
          class="img-fluid"
          style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);"
          :src="preview"
          alt
        >
        <img
          v-else
          class="img-fluid"
          style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);"
          src="@/static/images/placeholder.png"
        >
      </div>
      <div class="col-md-4">
        <form @submit.prevent="submitRecipe">
          <div class="form-group">
            <label for>Recipe Name</label>
            <input type="text" class="form-control" v-model="recipe.name">
          </div>
          <div class="form-group">
            <label for>Ingredients</label>
            <input v-model="recipe.ingredients" type="text" class="form-control">
          </div>
          <div class="form-group">
            <label for>Food picture</label>
            <input type="file" name="file" @change="onFileChange">
          </div>
          <div class="row">
            <div class="col-md-6">
              <div class="form-group">
                <label for>Difficulty</label>
                <select v-model="recipe.difficulty" class="form-control">
                  <option value="Easy">Easy</option>
                  <option value="Medium">Medium</option>
                  <option value="Hard">Hard</option>
                </select>
              </div>
            </div>
            <div class="col-md-6">
              <div class="form-group">
                <label for>
                  Prep time
                  <small>(minutes)</small>
                </label>
                <input v-model="recipe.prep_time" type="number" class="form-control">
              </div>
            </div>
          </div>
          <div class="form-group mb-3">
            <label for>Preparation guide</label>
            <textarea v-model="recipe.prep_guide" class="form-control" rows="8"></textarea>
          </div>
          <button type="submit" class="btn btn-primary">Submit</button>
        </form>
      </div>
    </div>
  </main>
</template>
<script>
export default {
  head() {
    return {
      title: "Add Recipe"
    };
  },
  data() {
    return {
      recipe: {
        name: "",
        picture: "",
        ingredients: "",
        difficulty: "",
        prep_time: null,
        prep_guide: ""
      },
      preview: ""
    };
  },
  methods: {
    onFileChange(e) {
      let files = e.target.files || e.dataTransfer.files;
      if (!files.length) {
        return;
      }
      this.recipe.picture = files[0];
      this.createImage(files[0]);
    },
    createImage(file) {
      // let image = new Image();
      let reader = new FileReader();
      let vm = this;
      reader.onload = e => {
        vm.preview = e.target.result;
      };
      reader.readAsDataURL(file);
    },
    async submitRecipe() {
      const config = {
        headers: { "content-type": "multipart/form-data" }
      };
      let formData = new FormData();
      for (let data in this.recipe) {
        formData.append(data, this.recipe[data]);
      }
      try {
        let response = await this.$axios.$post("/recipes/", formData, config);
        this.$router.push("/recipes/");
      } catch (e) {
        console.log(e);
      }
    }
  }
};
</script>
<style scoped>
</style>

Метод submitRecipe() вызывается во время публикации данных формы и успешного создания рецепта приложение и далее пользователь перенаправляется в /recipes/ с помощью this.$router.

Создание страницы просмотра одного рецепта

Далее давайте создадим вьюху, позволяющую пользователю просматривать один элемент Recipe. Для этого откройте файл /pages/recipes/_id/index.vue и вставьте в него следующий фрагмент:

<template>
  <main class="container my-5">
    <div class="row">
      <div class="col-12 text-center my-3">
        <h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2>
      </div>
      <div class="col-md-6 mb-4">
        <img
          class="img-fluid"
          style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);"
          :src="recipe.picture"
          alt
        >
      </div>
      <div class="col-md-6">
        <div class="recipe-details">
          <h4>Ingredients</h4>
          <p>{{ recipe.ingredients }}</p>
          <h4>Preparation time ⏱</h4>
          <p>{{ recipe.prep_time }} mins</p>
          <h4>Difficulty</h4>
          <p>{{ recipe.difficulty }}</p>
          <h4>Preparation guide</h4>
          <textarea class="form-control" rows="10" v-html="recipe.prep_guide" disabled />
        </div>
      </div>
    </div>
  </main>
</template>
<script>
export default {
  head() {
    return {
      title: "View Recipe"
    };
  },
  async asyncData({ $axios, params }) {
    try {
      let recipe = await $axios.$get(`/recipes/${params.id}`);
      return { recipe };
    } catch (e) {
      return { recipe: [] };
    }
  },
  data() {
    return {
      recipe: {
        name: "",
        picture: "",
        ingredients: "",
        difficulty: "",
        prep_time: null,
        prep_guide: ""
      }
    };
  }
};
</script>
<style scoped>
</style>

Код здесь довольно прост. Новое, что мы здесь представляем, это атрибут params, в методе asyncData(). В этом случае мы используем params, чтобы получить идентификатор рецепта, который мы хотим просмотреть. Мы извлекаем параметры из URL и предварительно выбираем их данные, прежде чем отобразить их на странице.

Теперь мы можем просмотреть один Recipe в веб-браузере:

Создание страницы редактирования одного рецепта

Далее нам нужно создать вьюху, позволяющее пользователю редактировать и обновлять выбранный рецепт, поэтому откройте файл /pages/recipes/_id/edit.vue и вставьте следующий код:

<template>
  <main class="container my-5">
    <div class="row">
      <div class="col-12 text-center my-3">
        <h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2>
      </div>
      <div class="col-md-6 mb-4">
        <img v-if="!preview" class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);"  :src="recipe.picture">
        <img v-else class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);"  :src="preview">
      </div>
      <div class="col-md-4">
        <form @submit.prevent="submitRecipe">
          <div class="form-group">
            <label for>Recipe Name</label>
            <input type="text" class="form-control" v-model="recipe.name" >
          </div>
          <div class="form-group">
            <label for>Ingredients</label>
            <input type="text" v-model="recipe.ingredients" class="form-control" name="Ingredients" >
          </div>
          <div class="form-group">
            <label for>Food picture</label>
            <input type="file" @change="onFileChange">
          </div>
          <div class="row">
            <div class="col-md-6">
              <div class="form-group">
                <label for>Difficulty</label>
                <select v-model="recipe.difficulty" class="form-control" >
                  <option value="Easy">Easy</option>
                  <option value="Medium">Medium</option>
                  <option value="Hard">Hard</option>
                </select>
              </div>
            </div>
            <div class="col-md-6">
              <div class="form-group">
                <label for>
                  Prep time
                  <small>(minutes)</small>
                </label>
                <input type="text" v-model="recipe.prep_time" class="form-control" name="Ingredients" >
              </div>
            </div>
          </div>
          <div class="form-group mb-3">
            <label for>Preparation guide</label>
            <textarea v-model="recipe.prep_guide" class="form-control" rows="8"></textarea>
          </div>
          <button type="submit" class="btn btn-success">Save</button>
        </form>
      </div>
    </div>
  </main>
</template>
<script>
export default {
  head(){
      return {
        title: "Edit Recipe"
      }
    },
  async asyncData({ $axios, params }) {
    try {
      let recipe = await $axios.$get(`/recipes/${params.id}`);
      return { recipe };
    } catch (e) {
      return { recipe: [] };
    }
  },
  data() {
    return {
      recipe: {
        name: "",
        picture: "",
        ingredients: "",
        difficulty: "",
        prep_time: null,
        prep_guide: ""
      },
      preview: ""
    };
  },
  methods: {
    onFileChange(e) {
      let files = e.target.files || e.dataTransfer.files;
      if (!files.length) {
        return;
      }
      this.recipe.picture = files[0]
      this.createImage(files[0]);
    },
    createImage(file) {
      let reader = new FileReader();
      let vm = this;
      reader.onload = e => {
        vm.preview = e.target.result;
      };
      reader.readAsDataURL(file);
    },
    async submitRecipe() {
      let editedRecipe = this.recipe
      if (editedRecipe.picture.indexOf("http://") != -1){
        delete editedRecipe["picture"]
      }
      const config = {
        headers: { "content-type": "multipart/form-data" }
      };
      let formData = new FormData();
      for (let data in editedRecipe) {
        formData.append(data, editedRecipe[data]);
      }
      try {
        let response = await this.$axios.$patch(`/recipes/${editedRecipe.id}/`, formData, config);
        this.$router.push("/recipes/");
      } catch (e) {
        console.log(e);
      }
    }
  }
};
</script>

<style>
</style>

В приведенном выше коде метод submitRecipe() имеет условный оператор, целью которого является удаление изображения отредактированного элемента рецепта из данных, если изображение не было изменено. После обновления элемента приложение перенаправит пользователя на страницу со списком рецептов – /recipes/.

Настройка переходов

Поздравляю! Приложение полностью функционально, и это здорово, однако мы можем придать ему более плавный вид, добавив переходы.

Переходы (Transitions) позволяют нам плавно изменять значения свойств CSS (от одного значения к другому) в течение заданного периода времени.

Мы настроим переходы в файле nuxt.config.js. По умолчанию уже заданы имена переходов на странице, что означает, что определенные нами переходы будут активны на всех страницах.

Давайте включим стиль для перехода. Создайте папку с именем css/ в каталоге assets/ и добавьте в нее файл transitions.css. Теперь откройте файл transitions.css и вставьте в него следующее:

.page-enter-active,
.page-leave-active {
  transition: opacity .3s ease;
}
.page-enter,
.page-leave-to {
  opacity: 0;
}

Откройте файл nuxt.config.js и обновите его следующим образом, чтобы загрузить файл CSS, который мы только что создали:

// nuxt.config.js

module.exports = {   /_
  ** Global CSS
  _/
  css: ['~/assets/css/transitions.css'], // update this
}

Вуаля! Видите, как легко добавить переходы в приложение. Теперь наше приложение плавно изменяет фреймы при каждой навигации😋:

Заключение

В этой статье мы рассказали о различиях между клиентскими и серверными приложениями. Далее мы рассказали, о универсальных приложениях, и наконец, мы рассказали о том как создать универсальное приложение с использованием Nuxt.js и Django.

Исходный код этого руководства доступен здесь, на GitHub.

Оригинал статьи: Jordan Irabor(@JordanIrabor)
Building a Universal Application with Nuxt.js and Django

Была ли вам полезна эта статья?
[13 / 4.5]

Spread the love
Подписаться
Уведомление о
guest
8 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
andre
andre
5 лет назад

после запуска $ npm run dev
выдало
TypeError
Cannot read property ‘extend’ of undefined в браузере

а в консоли:
19:09:18 ERROR (node:2672) DeprecationWarning: Tapable.plugin is deprecated. Use new API on .hooks instead

andre
andre
5 лет назад

Ну, я как бы не настоящий сварщик, до этого с node вообще не имел дела, только очень приблизительно представлял себе как это работает

получить страницу на указанном этапе получилось с помощью следующих телодвижений:

установка

npm install -g npm-install-missing

и запуск

npm-install-missing

выдал, что версия bootstrap-vue
ниже чем требуемая

npm install –save bootstrap-vue

при этом оказалось что не хватает jquery

npm install –save jquery

после этого страница появилась, а в консоли ошибка осталась

andre
andre
5 лет назад

Следующий косяк: http://prntscr.com/n9hec3

похоже статья устарела на следующий день после выхода

andre
andre
5 лет назад

Конечно сам пример неплохой, но не рассмотрен ряд вопросов которые сопутствуют теме:

1. Деплой на боевой сервер
2. Добавление других фичей

и да, странно уйти от Django к DRF чтобы продолжать генерировать страницы на сервере, правда уже другом, дополнительном. По итогу система становится сложнее (больше компонентов, значит больше вероятность отказа)

чувак
чувак
3 лет назад

choose linting tools:eslint ,prettier ,lsf,stylelint?
который из них нужно выбрать?

Алексей
Алексей
3 лет назад

Топ статья конечно. Автор, а есть опыт, как мне скрыть мое апи, что бы его никто не видел? Во-вторых, я бы не хотел что бы на моем сайте любой желающий мог удалять что-то или изменять. Как настроить разграничение прав. Спасибо

Анонимно
Анонимно
2 лет назад
Reply to  Алексей

никак, доступно для всех, можешь добавишь различные пермишен классы во вьюсете, например IsAuth