Создание Instagram-клона на VueJS workshop

Создание приложения

В этой статье мы рассмотрим шаги создания упрощённого клона Instagram с возможностью добавлять посты, фильтры фото и лентой постов, все как в настоящем Instagram!
Вот, что должно получиться в конце:

В приложении можно загрузить изображение, кликнув по иконке “квадрат” в футере. После загрузки изображения его можно отредактировать, используя разные фильтры, а также можно разделять текст на блоки.

В этом уроке рассмотрим созданиe пользовательского интерфейса приложения с помощью Vue.js, сосредоточимся на настройке интерфейса UI, никаких взаимодействий с сервером не планируется, будем редатрировать только фронтенд.

Контекст

Предполагаю, что вы немного знакомы с Vue, если что-то непонятно, то разберетесь в процессе написания кода.

В этом уроке мы поговорим о следующем:

  • Развертывание проекта с помощью vue-cli.
  • Создание компонентов в формате одного файла single-file.
  • Обмен данными и событиями между компонентами.
  • Загрузка файлов с помощью API FileReader.
  • Редактирование изображений с помощью Instagram-подобных фильтров с использованием библиотеки CSSGram (Автор @Una).
  • Перетаскивание прокрутки элементов с помощью библиотеки vue-dragscroll (Автор @don_jon243).

Чтобы сосредоточиться на использовании Vue, мы не будем разбирать стилизацию приложения, просто добавим необходимые стили и вызовем их.

Блоки кода

В процессе создания приложения я буду показывать изменения кода и окончательные файлы. Для показа целых файлов я буду использовать Github gist`ы, когда нужно будет показать какие-то конкретные изменения, то я буду показывать их кусочками файлов:

<div id="app">
  Добавляем этот текст всередине элемента!
</div>

Подготовка приложения

Отправной точкой нашего приложения будет использование интерфейса командной строки Vue (vue-cli). Vue-cli - это инструмент созданный командой Vue, который помогает упростить и ускорить создание и разработку приложений Vue. Инструмент объединит наше приложение с менеджером пакетов Webpack, который в свою очередь позволяет создавать компоненты в формате одного файла - Single-file.

Для этого урока мы создадим с помощью vue-cli заготовку в CodeSandBox, редактор онлайн-кода, ориентированный на прототипирование и развертывание веб-приложений.

Для старта используем некоторый исходный шаблон приложения.

Давайте кратко рассмотрим структуру исходного каталога проекта.

data/
  filters.js
  posts.js
styles/
  app.scss
  filter-type.scss
  instagram-post.scss
App.vue
index.html
index.js
package.json

Примечание: Редактор CodeSandbox включает файлы конфигурации, необходимые для настройки нашего приложения. Это позволит нам просто сосредоточиться на коде приложения.

data/

Папка data/ содержит данные в приложении, в двух отдельных файлах: filters.js и posts.js.

Файл filters.js ссылается на тип фильтров, который может быть применен к загруженному изображению:

export default [
  { name: "normal" },
  { name: "clarendon" },
  { name: "gingham" },
  { name: "moon" },
  { name: "lark" },
  { name: "reyes" },
  { name: "juno" },
  { name: "slumber" },
  { name: "aden" },
  { name: "perpetua" },
  { name: "mayfair" },
  { name: "rise" },
  { name: "hudson" },
  { name: "valencia" },
  { name: "xpro2" },
  { name: "willow" },
  { name: "lofi" },
  { name: "inkwell" },
  { name: "nashville" }
];

posts.js представляет собой набор объектов данных, которые представляют сообщения, которые уже были отправлены в ленту. Если наше приложение сохраняло данные на сервере, мы, вероятно, сделаем запрос GET на сервер для получения подобной информации:

export default [
  {
    username: "socleansofreshh",
    userImage: "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/me_3.jpg",
    postImage:
      "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/tropical_beach.jpg",
    likes: 36,
    hasBeenLiked: false,
    caption: "When you're ready for summer '18 ☀️",
    filter: "perpetua"
  },
  {
    username: "djirdehh",
    userImage: "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/me2.png",
    postImage:
      "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/downtown.jpg",
    likes: 20,
    hasBeenLiked: false,
    caption: "Views from the six...",
    filter: "clarendon"
  },
  {
    username: "puppers",
    userImage:
      "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/pug_personal.jpg",
    postImage:
      "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/puppers.jpg",
    likes: 49,
    hasBeenLiked: false,
    caption: "Current mood",
    filter: "lofi"
  }
];

Каждый объект post содержит свойства, относящиеся к нему, такие как имя, изображение автора поста, загруженное изображение, кол-во лайков и т.д. Кроме того, каждый объект данных будет содержать свойство filter, которое будет определять применение фильтра к изображению поста.

styles/

Папка styles/ содержит все кастомные CSS, необходимые для нашего приложения. Когда мы будем создавать компоненты, мы будем привязывать правильные стили к компоненту и сфокусируемся на использовании Vue.

App.vue

Папка App.vue - это основной родительский компонент, который должен быть отображен из нашего экземпляра Vue. Если мы откроем файл App.vue, мы увидим простой однофайловый компонент:

<template>
  <div id="app">
    Давайте создадим инстаграм клон!
  </div>
</template>

<script>
export default {
  name: "App"
};
</script>

<style lang="scss" src="./styles/app.scss">
// Стили
</style>

Однофайловые компоненты - это невероятно полезная фича, которая позволяет нам определять все HTML/CSS и JS компоненты в одном vue файле.

В нашем файле App.vue компонент в данный момент просто отображает простое приветственное сообщение.

index.html

Файл index.html является корневой страницей разметки нашего приложения:

<!-- Stylesheets -->
<link rel="stylesheet" href="https://cdn.rawgit.com/jgthms/bulma/9e1752b5/css/bulma.css">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.13/css/all.css">
<link rel="stylesheet" href="https://cssgram-cssgram.netdna-ssl.com/cssgram.min.css">

<!-- App -->
<div id="app"></div>

В index.html мы указываем внешние стили для нашего приложения. Мы добавили Bulma в качестве CSS фреймворка, Font Awesome для иконок и CSSGram для реализации фильтров Instagram.

Элемент div с id приложения является элементом DOM, на который будет примонтировано приложение Vue, как это задано в файле index.js.

index.js

Файл index.js представляет собой отправную точку Vue приложения:

import Vue from "vue";
import App from "./App";

/* eslint-disable no-new */
new Vue({
  el: "#app",
  render: h => h(App)
});

В верхней части файла index.js мы импортируем библиотеку Vue и компонент App. Затем мы создаем новый экземпляр Vue, объявляя его через new Vue ({…}). Экземпляр Vue является отправной точкой для всех приложений Vue и принимает объект параметров options, содержащий детали экземпляра.

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

Теперь, когда у нас есть представление о том, как реализована структура нашего приложения, мы можем начать создавать приложение.

1) Домашняя страница

Первое, что мы начнем делать - это создавать ленту постов Главной страницы, создав основной компонент приложения App и привязав данные из файла data/posts.js.

На высоком уровне мы можем разбить компоновку будущего приложения на три раздела: шапку (phone-header), тело (phone-body) и подвал (phone-footer).

Большинство функциональных возможностей приложения будут находится в секции phone-body; поэтому было бы целесообразно создать отдельный компонент, который представлял бы этот раздел. Мы создадим этот компонент как PhoneBody.vue в папке components/:

components/  
  PhoneBody.vue  
data/  
styles/  
...

В файле PhoneBody.vue мы создадим простой однофайловый компонент:

<template>
  <div class="phone-body">
    This is the Phone Body
  </div>
</template>

<script>
export default {
  name: "PhoneBody"
};
</script>

<style lang="scss" src="../styles/phone-body.scss">
// Styles from stylesheet
</style>

В теге script файла App.vue мы импортируем компонент PhoneBody и определим его свойства в components

<template>  
  ...  
</template>  
  
<script>  
import PhoneBody from "./components/PhoneBody"; 
  
export default {  
  name: "App",  
  components: {  
    "phone-body": PhoneBody  
  }  
};  
</script>  
  
<style lang="scss" src="./styles/app.scss">  
// Styles from stylesheet  
</style>

Мы сопоставили объявление phone-body с объектом компонента PhoneBody. Это позволит нам объявить недавно импортированный компонент в качестве phone-body в шаблоне приложения. Мы сделаем это и, кроме того, создадим элементы, которые представляют разделы шапки (phone-header) и подвала (phone-footer). Наш файл App.vue станет таким:

<template>
  <div id="app">
    <div class="app-phone">
      <div class="phone-header">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/vue_gram_logo_cp.png" />
      </div>
      <phone-body />
      <div class="phone-footer">
       <div class="home-cta">
        <i class="fas fa-home fa-lg"></i>
       </div>
       <div class="upload-cta">
        <i class="far fa-plus-square fa-lg"></i>
       </div>
      </div>
    </div>
  </div>
</template>

<script>
import PhoneBody from "./components/PhoneBody";
export default {
  name: "App",
  components: {
    "phone-body": PhoneBody
  }
};
</script>

<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Мы разместили изображение лого Instagram в phone-header; и иконки Главная и загрузить в phone-footer. На данный момент наше приложение будет выглядеть так:

Прежде чем мы сможем заполнять контент в разделе phone-body, давайте оценим данные, представляющие приложение.

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

Для начала мы будем импортировать сообщения и фильтровать массивы данных и объявлять их как свойства данных с тем же именем в функции data() компонента App.

<template>
  ...
</template>

<script>
import PhoneBody from "./components/PhoneBody";
import posts from "./data/posts";
import filters from "./data/filters";
export default {
  name: "App",
  data() {
    return {
      posts,
      filters,
    };
  },
  components: {
    "phone-body": PhoneBody
  }
};
</script>

<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Затем мы можем передавать массивы в posts и filters как параметры, через которые мы объявляем компонент phone-body. Мы будем использовать сокращенный синтаксис для директив v-bind:

<template>
  <div id="app">
    <div class="app-phone">
      <div class="phone-header">
        ...
      </div>
      <phone-body 
        :posts="posts"
        :filters="filters" />
      <div class="phone-footer">
       ...
      </div>
    </div>
  </div>
</template>
<script>
..
export default {
  name: "App",
  ...
}
</script>
<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

С этими изменениями весь файл App.vue будет выглядеть так:

<template>
  <div id="app">
    <div class="app-phone">
      <div class="phone-header">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/vue_gram_logo_cp.png" />
      </div>
      <phone-body 
        :posts="posts"
        :filters="filters" />
      <div class="phone-footer">
       <div class="home-cta">
        <i class="fas fa-home fa-lg"></i>
       </div>
       <div class="upload-cta">
        <i class="far fa-plus-square fa-lg"></i>
       </div>
      </div>
    </div>
  </div>
</template>

<script>
import PhoneBody from "./components/PhoneBody";

import posts from "./data/posts";
import filters from "./data/filters";

export default {
  name: "App",
  data() {
    return {
      posts,
      filters,
    };
  },
  components: {
    "phone-body": PhoneBody
  }
};
</script>

<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

С массивом posts, доступным в виде параметров в компоненте PhoneBody, мы теперь можем отрендерить список элементов, которые являют собою отправленные посты. Отрендерим список с помощью директивыо Vue v-for.

Так как каждый пост содержит много разметки мы создадим компонент VuegramPost, который будет использоваться директивой v-for для построения списка. В папке components/ мы создадим файл компонента VuegramPost.vue:

components/
 VuegramPost.vue
 PhoneBody.vue
data/
styles/

Компонент VuegramPost будет шеллом, который выводит свойства объекта единичного объекта post:

Продолжим и создадим компонент VuegramPost в файле VuegramPost.vue:

<template>
  <div class="vuegram-post">
    <div class="header level">
        <div class="level-left">
          <figure class="image is-32x32">
            <img :src="post.userImage" />
          </figure>
          <span class="username">{{post.username}}</span>
        </div>
    </div>
    <div class="image-container"
      :class="post.filter"
      :style="{ backgroundImage: 'url(' + post.postImage + ')' }">
    </div>
    <div class="content">
      <div class="heart">
        <i class="far fa-heart fa-lg"></i>
      </div>
      <p class="likes">{{post.likes}} likes</p>
      <p class="caption"><span>{{post.username}}</span> {{post.caption}}</p>
    </div>
  </div>
</template>

<script>
export default {
  name: "VuegramPost",
  props: {
    post: Object
  }
};
</script>

<style lang="scss" src="../styles/vuegram-post.scss">
// Styles from stylesheet
</style>

Тут есть несколько моментов, на которые стоит обратить внимание:

  1. В script мы заявили, что компонент VuegramPost ожидает параметры объекта post, как показано в требовании проверки параметров (props: {post: Object}).
  2. Мы связываем параметры объекта post c компонентом шаблона используя синтаксис Mustache: ( {{ }} ) и директиву :v-bind.
  3. Мы указываем, что стили компонента должны быть получены из стилей vuegram-post.scss.

Теперь нам нужно использовать директиву v-for для рендеринга списка VuegramPost компонентов на основе коллекции данных posts. В файле PhoneBody.vue мы сначала объявим posts и filters параметры, которые передаются нам, чтобы мы могли использовать их в компоненте PhoneBody. Мы сделаем это, указав требование проверки параметра - они оба posts и filters должны быть массивами:

<template>  
  <div class="phone-body">  
    This is the Phone Body  
  </div>  
</template>

<script>  
export default {  
  name: "PhoneBody",  
  props: {  
    posts: Array,  
    filters: Array  
  } 
};  
</script>

<style lang="scss" src="../styles/phone-body.scss">  
// Styles from stylesheet  
</style>

Мы также импортируем компонент VuegramPost и объявим его в свойстве components компонента PhoneBody:

<template>
  <div class="phone-body">
    This is the Phone Body
  </div>
</template>
<script>
import VuegramPost from "./VuegramPost";

export default {
  name: "PhoneBody",
  props: {
    posts: Array,
    filters: Array
  },
  components: {
    "vuegram-post": VuegramPost
  }
};
</script>
<style lang="scss" src="../styles/phone-body.scss">
// Styles from stylesheet
</style>

Наконец, мы отобразим список постов в шаблоне и файл PhoneBody.vue обновится до следующего вида:

<template>
  <div class="phone-body">
    <div class="feed">
      <vuegram-post v-for="(post,i) in posts"
        :post="post"
        :key="i">
      </vuegram-post>
    </div>
  </div>
</template>

<script>
import VuegramPost from "./VuegramPost";

export default {
  name: "PhoneBody",
  props: {
    posts: Array,
    filters: Array
  },
  components: {
    "vuegram-post": VuegramPost
  }
};
</script>

<style lang="scss" src="../styles/phone-body.scss">
// Styles from stylesheet
</style>

Поскольку мы итерируем по коллекции posts, то post - это отдельный пост в директиве v-for. В каждый отрендеренный vuegram-post мы также передаем итерированный объект post и его параметры могут быть доступны в компоненте.

Теперь мы можем прокручивать ленту постов в нашем приложении!

Перед продолжением, давайте разберемся какой функционал “лайков” нужно будет добавить для постов Instagram. В реальном Instagram мы можем добавить в избранное щелкнув по иконке сердца под изображением поста или дважды щелкнув это изображение.

У нас есть два параметра в объекте post, связанных с функциональностью “лайков”, которые мы собираемся реализовать:

{
  ...,
  likes: 36,
  hasBeenLiked: false,
  ...,
},

likes - это число, которое мы будем увеличивать на один, когда пользователь «лайкает» пост. Поскольку мы не будем позволять пользователю постоянно увеличивать количество лайков к одному и тому же посту, hasBeenLiked будет той переменной логического типа, которая будет показывать лайкнут ли пост.

В script компонента VuegramPost мы сначала создадим метод like() в свойстве methods(), который будет “лайкать” или отменять “лайк”:

<template>
  <div class="vuegram-post">
    ...
  </div>
</template>
<script>
export default {
  name: "VuegramPost",
  props: {
    post: Object
  },
  methods: {
    like() {
      this.post.hasBeenLiked
        ? this.post.likes--
        : this.post.likes++;
      this.post.hasBeenLiked = !this.post.hasBeenLiked;
    }
  }
};
</script>
<style lang="scss" src="../styles/instagram-post.scss">
// Styles from stylesheet
</style>

В методе like() мы используем тернарный оператор, чтобы условно увеличить или уменьшить post.likes значение, основанное на значении post.hasBeenLiked. Затем мы переключаем значение переменной логического типа post.hasBeenLiked.

В шаблоне теперь мы можем добавить обработчики событий на элементах, которые пользователь будет нажимать, чтобы лайкнуть пост. Мы будем использовать сокращенный синтаксис для директивы v-on и укажем обработчик клика dblclick на изображении поста и обработчик клика click на иконке сердца.

<template>
  <div class="vuegram-post">
    <div class="header level">
      ...
    </div>
    <div class="image-container"
      :class="post.filter"
      :style="{ backgroundImage: 'url(' + post.postImage + ')' }"
      @dblclick="like">
    </div>
    <div class="content">
      <div class="heart">
        <i class="far fa-heart fa-lg"
          :class="{'fas': this.post.hasBeenLiked}"
          @click="like">
        </i>
      </div>
      ...
    </div>
  </div>
</template>
<script>
export default {
  name: "Vuegram",
  ...
}
</script>
<style lang="scss" src="../styles/vuegram-post.scss">
// Styles from stylesheet
</style>

Мы также применили привязку условного класса к иконке сердца, чтобы условно добавить класс .fas, если логическая переменная post.hasBeenLiked истинна. Этот класс заполняет иконку сердца красным цветом, показывая, что пост понравился пользователю.

С этими изменениями файл VuegramPost.vue будет иметь следующий вид:

<template>
  <div class="vuegram-post">
    <div class="header level">
        <div class="level-left">
          <figure class="image is-32x32">
            <img :src="post.userImage" />
          </figure>
          <span class="username">{{post.username}}</span>
        </div>
    </div>
    <div class="image-container"
      :class="post.filter"
      :style="{ backgroundImage: 'url(' + post.postImage + ')' }"
      @dblclick="like">
    </div>
    <div class="content">
      <div class="heart">
        <i class="far fa-heart fa-lg"
          :class="{'fas': this.post.hasBeenLiked}"
          @click="like">
        </i>
      </div>
      <p class="likes">{{post.likes}} likes</p>
      <p class="caption"><span>{{post.username}}</span> {{post.caption}}</p>
    </div>
  </div>
</template>

<script>
export default {
  name: "VuegramPost",
  props: {
    post: Object
  },
  methods: {
    like() {
      this.post.hasBeenLiked ? this.post.likes-- : this.post.likes++;
      this.post.hasBeenLiked = !this.post.hasBeenLiked;
    }
  }
};
</script>

<style lang="scss" src="../styles/vuegram-post.scss">
// Styles from stylesheet
</style>

Теперь, когда пользователь либо щелкает иконку сердца, либо дважды кликает на изображении, он/она «лайкнет» сообщение:

Ниже приведен пример нашего приложения на этом этапе!

2) Процесс отправки

Когда пользователь начинает процесс отправки, мы хотим изменить UI (пользовательский интерфейс) в зависимости от того, где находится пользователь (например, если пользователь находится на втором этапе, ему/ей должно быть предложено выбрать фильтр, который должен быть применен к изображению). В приложении из реального мира мы, вероятно, захотели бы использовать соответствующую библиотеку маршрутизации, такую как Vue Router, или, по крайней мере, построить настраиваемое решение маршрутизации.

Однако, для нашего приложения мы сделаем что-то гораздо проще. Мы изменим UI приложения на основе параметра step. Когда пользователь находится на step === 1, ему/ей будет показан лента новостей. На step === 2 будет возможность выбрать фильтр, и на этапе step === 3 пользователю будет предложено предоставить описание поста.

Элементы, отображаемые в заголовке (например, ссылки «Cancel» и «Next»), также будут условно отображаться на основе значения step.

В родительском компоненте App давайте сначала представим step свойство и установим его значение равным 1. Поскольку мы будем использовать свойство step, чтобы определять, как будут отображаться элементы в секции phone-body, мы передадим его как параметры для компонента phone-body.

<template>
  <div id="app">
    <div class="app-phone">
      ...
      <phone-body
        :step="step"
        :posts="posts"
        :filters="filters"
      />
      ...
    </div>
  </div>
</template>
<script>
...
export default {
  name: "App",
  data() {
    return {
      step: 1,
      posts,
      filters
    };
  },
  ...
};
</script>

Когда пользователь проходит процесс отправки; мы хотим, чтобы конечный результат включал отправку нового поста на ленту домашней страницы. Иными словами, мы хотим, чтобы пользователь мог пушнуть (т.е. представить) новый пост объект в коллекцию сообщений (т.е. в файл posts.js). Мы будем управлять свойствами username и userImage, но нам нужно будет полчуать остальное от пользователя.

В общем, нам нужно захватить выбранный фильтр selectedFilter, который хочет применить пользователь, изображение image, которое они хотели бы загрузить, и подпись к сообщению caption. Учитывая эти свойства, давайте объявим для них пустые начальные значения в компоненте App и передадим их как параметры в phone-body:

<template>
  <div id="app">
    <div class="app-phone">
      ...
      <phone-body
        :step="step"
        :posts="posts"
        :filters="filters"
        :image="image"
        :selectedFilter="selectedFilter"
        v-model="caption"
      />
      ...
    </div>
  </div>
</template>
<script>
...
export default {
  name: "App",
  data() {
    return {
      step: 1,
      posts,
      filters,
      image: "",
      selectedFilter: "",
      caption: ""
    };
  },
...
};
</script>

Обратите внимание на то, как мы используем директиву v-model для привязки значения параметра caption. Это связано с тем, что мы хотим избежать прямой мутации параметра caption родительского элемента из дочернего компонента. Объясню это чуть подробнее в конце статьи.

При инициализации основных свойств мы создадим метод, ответственный за направление пользователя с шага 1 на шаг 2. Одним из действий, ответственных за это, будет загрузка изображения по клику на иконку загрузки в футере (phone-footer):

В App.vue мы создадим новый метод uploadImage(), который будет нести ответственность за загрузку изображения и затем направлять пользователя на шаг 2.

Элемент input type = “file” позволяет пользователям выбирать один или несколько файлов с их устройства для загрузки. Поэтому в разделе футере шаблона App.vue мы создадим элемент ввода (type=“file”), в котором есть слушатель событий, который вызывает метод uploadImage() при срабатывании:

<template>
  <div id="app">
    <div class="app-phone">
      ...
      <div class="phone-footer">
        ...
        <div class="upload-cta">
          <input type="file"
            name="file"
            id="file"
            class="inputfile"
            @change="uploadImage"/>
          <label for="file">
            <i class="far fa-plus-square fa-lg"></i>
          </label>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
...
export default {
  name: "App",
  ...
};
</script>
<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Поскольку мы хотим, чтобы иконка загрузки являлась действием, связанным с загрузкой изображения, мы установим visibility: hidden для класса, применяемого input. Кроме того, мы обернули элемент label for = “file” вокруг иконки загрузки. Когда пользователь нажимает на иконку загрузки (т.е. элемент label), он будет обрабатываться так, как если бы он кликнул на input.

Элемент input имеет слушатель событий change, который вызывает метод uploadImage() при срабатывании. Мы создадим этот сопроводительный метод uploadImage() в свойстве methods в script теге файла App.vue. Чтобы загрузить изображения, мы будем использовать API FileReader. Вот метод uploadImage() в целом:

<template>
  <div id="app">
    ...
  </div>
</template>
<script>
...
export default {
  name: "App",
  ...
  methods: {
    uploadImage(evt) {
      const files = evt.target.files;
      if (!files.length) return;
      const reader = new FileReader();
      reader.readAsDataURL(files[0]);
      reader.onload = evt => {
        this.image = evt.target.result;
        this.step = 2;
      };
      // To enable reuploading of same files in Chrome
      document.querySelector("#file").value = "";
    }
  },
  ...
};
</script>
<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Давайте рассмотрим, что делает этот метод.

  1. Когда пользователь загружает изображение, объект события имеет список объектов файлов, к которым можно получить доступ из evt.target.files.
  2. Если файлов нет, мы возвращаемся раньше. Если файлы существуют, мы продолжаем установку new FileReader() в переменную reader.
  3. FileReader() позволяет асинхронно читать содержимое файлового объекта. Мы используем функцию readAsDataUrl из переменной reader для чтения содержимого загруженного файла (то есть первого файлового объекта file из evt.target.files).
  4. Когда содержимое файла считывается с readAsDataUrl, запускается обработчик события onload, с помощью которого мы используем для установки параметра компонента image в значение target.result события. Затем мы устанавливаем значение step компонента равным 2.
  5. Браузер Chrome не запускает событие изменения, если мы решили загрузить одно и то же изображение дважды. Чтобы выполнить небольшое изменение, чтобы обойти это, мы прямо установили значение поля ввода в пустую строку в конце метода. Теперь, когда пользователь снова пытается повторно загрузить тот же файл; это всегда будет воспринято как событие изменения change.

Со всеми изменениями, которые мы изложили выше; файл App.vue будет выглядеть следующим образом:

<template>
  <div id="app">
    <div class="app-phone">
      <div class="phone-header">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/vue_gram_logo_cp.png" />
      </div>
      <phone-body
        :step="step"
        :posts="posts"
        :filters="filters"
        :image="image"
        :selectedFilter="selectedFilter"
        v-model="caption"
      />
      <div class="phone-footer">
       <div class="home-cta">
        <i class="fas fa-home fa-lg"></i>
       </div>
       <div class="upload-cta">
          <input type="file"
            name="file"
            id="file"
            class="inputfile"
            @change="uploadImage"/>
          <label for="file">
            <i class="far fa-plus-square fa-lg"></i>
          </label>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import PhoneBody from "./components/PhoneBody";

import posts from "./data/posts";
import filters from "./data/filters";

export default {
  name: "App",
  data() {
    return {
      step: 1,
      posts,
      filters,
      image: "",
      selectedFilter: "",
      caption: ""
    };
  },
  methods: {
    uploadImage(evt) {
      const files = evt.target.files;
      if (!files.length) return;

      const reader = new FileReader();
      reader.readAsDataURL(files[0]);
      reader.onload = evt => {
        this.image = evt.target.result;
        this.step = 2;
      };

      // To enable reuploading of same files in Chrome
      document.querySelector("#file").value = "";
    }
  },
  components: {
    "phone-body": PhoneBody
  }
};
</script>

<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

В этот момент мы сможем успешно загружать файлы изображений.

Примечание: Для простоты мы не будем создавать функции ограничения загрузки пользователем определенных файлов изображений. Если пользователь загружает не изображения (т.е. не .jpg, .png, .gif), то ничего не будет отображаться на месте изображения.

Ленту домашней страницы следует отображать только в том случае, если пользователь находится на первом этапе. Чтобы проверить это, мы добавим оператор v-if для соответствующего элемента DOM в файле PhoneBody.vue со step === 1. Чтобы получить доступ к передаваемому параметру step, мы определим его в свойстве компонентов props:

<template>
  <div class="phone-body">
    <div v-if="step === 1" class="feed">
      <instagram-post v-for="(post,i) in posts"
        :post="post"
        :key="i">
      </instagram-post>
    </div>
  </div>
</template>
<script>
...
export default {
  name: "PhoneBody",
  props: {
    step: Number,
    posts: Array,
    filters: Array
  },
...
};
</script>
<style lang="scss" src="../styles/phone-body.scss">
// Styles from stylesheet
</style>

Теперь лента будет отображаться только тогда, когда пользователь находится на первом начальном этапе.

3) Экран фильтра

Предварительный просмотр изображения и список фильтров

Когда пользователь загружает изображение и направляется на 2 этап, мы хотим отобразить выбранное изображение и фильтры, которые можно применить к нему.

Прежде чем мы начнем отображать список фильтров, давайте создадим стартовый шаблон для этого второго шага. Мы начнем с добавления нового элемента div, который условно отображается, когда параметр step равен 2 в шаблоне компонента PhoneBody. Поскольку мы будем использовать image, который загрузил пользователь, мы также объявим этот параметр в props компонента.

<template>
  <div class="phone-body">
    <div v-if="step === 1" class="feed">
      ...
    </div>
    <div v-if="step === 2">
      <div class="selected-image"
        :style="{ backgroundImage: 'url(' + image + ')' }"></div>
      <div class="filter-container">
        <!-- Where filter choices will be -->
      </div>
    </div>
  </div>
</template>
<script>
...
export default {
  name: "PhoneBody",
  props: {
    step: Number,
    posts: Array,
    filters: Array,
    image: String
  },
...
};
</script>

Мы установили, что ожидаемый тип файла image должен быть String, поскольку значение аттрибута input файла часто представлен в виде строки.

Когда мы нажимаем иконку загрузки и загружаем изображение, мы перенаправляемся на шаг 2:

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

Мы создадим новый компонент FilterType, который будет отвечать за отображение фильтра на изображении.

В компоненте/папке мы создадим новый файл компонента FilterType.vue:

components/  
 FilterType.vue 
 InstagramPost.vue  
 PhoneBody.vuedata/  
styles/  
...

Сначала мы заполним файл компонента FilterType.vue исходным содержимым:

<template>
  <div class="filter-type">
    <p>{{filter.name}}</p>
    <div class="img"
      :class="filter.name"
      :style="{ backgroundImage: 'url(' + image + ')' }">
    </div> 
  </div>
</template>

<script>
export default {
  name: "FilterType",
  props: {
    filter: Object,
    image: String
  }
};
</script>

<style lang="scss" src="../styles/filter-type.scss">
// Styles from stylesheet
</style>

Давайте рассмотрим, что содержит этот компонент:

  1. Мы используем синтаксис Mustache для привязки filter.name к шаблону.
  2. Шаблон также содержит изображениe, которому выставлены стиль background-image, который в свою очередь привязан к image свойству.
  3. К этому же изображению мы привязываем класс filter.name. Это нам позволит использовать преимущества библиотеки CSSGram. Мы будем применять фильтры подобные Instagram к изображениям просто добавляя класс с именем фильтра непосредственно к элементу.
  4. Мы валидируем свойство filter и image, которые использует этот компонент.
  5. И в конце мы задаём, что источник стилей компонентов находится в файле styles/filter-type.scss.

В файле PhoneBody.vue теперь мы можем отобразить список компонентов FilterType. Мы будем отображать этот список в элементе div class = “filter-container” и файл PhoneBody.vue станет таким:

<template>
  <div class="phone-body">
    <div v-if="step === 1" class="feed">
      ...
    </div>
    <div v-if="step === 2">
      <div class="selected-image"
        :style="{ backgroundImage: 'url(' + image + ')' }">
      </div>
      <div class="filter-container">
        <filter-type v-for="(filter,i) in filters"
          :filter="filter"
          :image="image"
          :key="i">
        </filter-type>
      </div>
    </div>
  </div>
</template>
<script>
import VuegramPost from "./VuegramPost";
import FilterType from "./FilterType";
export default {
  name: "PhoneBody",
  ...,
  components: {
    "vuegram-post": VuegramPost,
    "filter-type": FilterType
  }
};
</script>

Мы импортируем компонент FilterType и объявляем его как тип фильтра в свойстве компонентов PhoneBody. Мы рендерим список filter-type компонентов на основе filter параметров, доступных в этом компоненте. Для каждого отрендереного элемента списка мы передаем итерированный объект filter и фактическое изображение image.

С обновлениями весь наш файл PhoneBody.vue будет выглядеть следующим образом:

<template>
  <div class="phone-body">
    <div v-if="step === 1" class="feed">
      <vuegram-post v-for="(post,i) in posts"
        :post="post"
        :key="i">
      </vuegram-post>
    </div>
    <div v-if="step === 2">
      <div class="selected-image"
        :style="{ backgroundImage: 'url(' + image + ')' }">
      </div>
      <div class="filter-container">
        <filter-type v-for="(filter,i) in filters"
          :filter="filter"
          :image="image"
          :key="i">
        </filter-type>
      </div>
    </div>
  </div>
</template>

<script>
import VuegramPost from "./VuegramPost";
import FilterType from "./FilterType";

export default {
  name: "PhoneBody",
  props: {
    step: Number,
    posts: Array,
    filters: Array,
    image: String
  },
  components: {
    "vuegram-post": VuegramPost,
    "filter-type": FilterType
  }
};
</script>

<style lang="scss" src="../styles/phone-body.scss">
// Styles from stylesheet
</style>

Теперь, когда пользователь перенаправляется на второй шаг - они смогут видеть и скроллить превью фильтров:

Выбор фильтра

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

Выбранная фильтрация в настоящее время передается от компонента приложения до PhoneBody. Чтобы в конечном итоге отобразить выбранный фильтр при просмотре изображения, нам нужно объявить опору и связать его с классом в элементе большого изображения div (т.e. div class="selected-image").

Это приведет к обновлению файла PhoneBody.vue:

<template>
  <div class="phone-body">
    ...
    <div v-if="step === 2">
      <div class="selected-image"
        :class="selectedFilter"
        :style="{ backgroundImage: 'url(' + image + ')' }">
      </div>
      <div class="filter-container">
        ...
      </div>
    </div>
  </div>
</template>
<script>
...
export default {
  name: "PhoneBody",
  props: {
    step: Number,
    posts: Array,
    filters: Array,
    image: String,
    selectedFilter: String
  },
  ...
};
</script>
<style lang="scss" src="../styles/phone-body.scss">
// Styles from stylesheet
</style>

Теперь у нас есть свойство selectedFilter, которое используется для определения типа фильтра, который следует применять на выбранном элементе div изображения. Теперь мы посмотрим, как мы можем изменить этот свойство selectedFilter, исходя из того, какой фильтр выбран пользователем. Поскольку мы будем излекать данные для компонента App из наследника наследника (PhoneBody), то будем с этой целью использовать кастомное событие

Компонент FilterType является дочерним компонентом компонента PhoneBody и внуком компонента App.

Когда пользователь нажимает на определенный тип фильтра из списка фильтров (т.e. кликает по компоненту FilterType), нам нужно сообщить компоненту App, что значение selectedFilter должно измениться. Поскольку мы будем отправлять информацию на два уровня наверх; то мы будем использовать EventBus для прямой передачи специального события на два уровня вверх при выборе типа фильтра.

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

Давайте посмотрим на это в действии. В корне папки; мы создадим файл event-bus.js:

...
styles/
App.vue
event-bus.js
...

В файле event-bus.js мы создадим и экспортируем новый экземпляр Vue:

import Vue from "vue";
const EventBus = new Vue();

export default EventBus;

Теперь мы можем начать создание диспетчера событий и слушатель.

Мы создадим диспетчер событий в файле FilterType.vue. В FilterType мы добавим слушатель кликов, который при срабатывании запускает настраиваемое событие filter-selected. Когда мы запускаем событие, мы передаем объект, который содержит значение выбранного фильтра. Мы будем использовать экземпляр EventBus для создания настраиваемого события:

<template>
  <div class="filter-type">
    <p>{{filter.name}}</p>
    <div class="img"
      :class="filter.name"
      :style="{ backgroundImage: 'url(' + image + ')' }"
      @click="selectFilter">
    </div> 
  </div>
</template>
<script>
import EventBus from "../event-bus.js";
export default {
  name: "FilterType",
  props: {
    filter: Object,
    image: String
  },
  methods: {
    selectFilter() {
      EventBus.$emit(
       "filter-selected", { filter: this.filter.name }
      );
    }
  }
};
</script>
<style lang="scss" src="../styles/filter-type.scss">
  // Styles from stylesheet
</style>

Далее в родительском компоненте приложения App мы создадим слушатель событий в хуке жизненного цикла компонентов created().

Хук created() запускается сразу как только Vue экземпляра/компонента был создан и можно получить доступ к данным экземпляра и его событиям.

Создав слушатель событий в хуке created(), мы создадим слушателя в момент создания компонента. В callback-функции слушателя мы устанавливаем свойство selectedFilter компонента приложения в значение filter из объекта обработчика события:

<template>
  <div id="app">
    ...
  </div>
</template>
<script>
...
import EventBus from "./event-bus.js";
export default {
  name: "App",
  ...
  created() {
    EventBus.$on("filter-selected", evt => {
      this.selectedFilter = evt.filter;
    });
  },
  ...
};
</script>
<style lang="scss" src="./styles/app.scss">
  // Styles from stylesheet
</style>

Теперь, когда мы выбираем фильтр из списка фильтров, свойство selectedFilter будет обновляться в приложении. Так как selectedFilter является параметром, переданным из компонента PhoneBody, наш UI будет ререндерить отображение выбраного фильтра на большом превью изображении:

Навигация вперед

Единственное, что осталось на этом этапе, это создать элементы навигации, чтобы пользователь мог перейти к 3 шагу или отменить процесс отправки.

В заголовке телефона мы хотим вывести «Cancel» и «Next», когда пользователь находится на 2 шаге. Фактически, мы хотим отобразить ссылку «Cancel», когда пользователь находится на 2 шаге . Это можно сделать используя v-if директиву,

<template>
  <div id="app">
    <div class="app-phone">
      <div class="phone-header">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/vue_gram_logo_cp.png" />
        <a class="cancel-cta"
           v-if="step === 2 || step === 3" 
           @click="goToHome">
            Cancel
        </a>
      </div>
      ...
    </div>
  </div>
</template>
<script>
...
export default {
  name: "App",
  ...
};
</script>
<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Мы добавили новый анкор элемент, который отображается только когда значение шага компонента равно 2 или 3. Мы также привязали слушатель кликов к этому элементу для вызова метода goToHome(), который ещё не определен.

Метод goToHome() будет возвращать пользователя на 1 шаг. К тому же он сбросит все потенциально заполняемые значения, которые мог ввести пользователь - например, image, selectedFilter и caption. Мы разместим этот метод в свойствах methods() компонента:

<template>
  <div id="app">
    ...
  </div>
</template>
<script>
...
export default {
  name: "App",
  ...,
  methods: {
    ...,
    goToHome() {
      this.image = "";
      this.selectedFilter = "";
      this.caption = "";
      this.step = 1;
    }
  },
  ...
};
</script>
<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Иконка «Главной» в футере телефона должна выполнять те же функции и направлять пользователя на первый шаг при нажатии.

В результате, мы присоединим тот же самый слушатель событий к иконке “Главной”:

<template>
  <div id="app">
    <div class="app-phone">
      ...
      <div class="phone-footer">
        <div class="home-cta" @click="goToHome">
          <i class="fas fa-home fa-lg"></i>
        </div>
        ...
      </div>
    </div>
  </div>
</template>
<script>
...
export default {
  name: "App",
  ...
};
</script>
<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Когда мы теперь кликнем по иконке «Главная» в нижнем колонтитуле телефона или ссылку «Cancel» в заголовке, мы вернемся к главному экрану.

Давайте также условно отобразим ссылку «Next». Ссылка Next будет отображаться только тогда, когда пользователь находится на step === 2, так как ссылка Share будет показана на следующем шаге. Мы добавим этот элемент ниже cancel-cta в шаблоне приложения:

<template>
  <div id="app">
    <div class="app-phone">
      <div class="phone-header">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/Instagram_logo.png" />
        <a class="cancel-cta"
           v-if="step === 2 || step === 3" 
           @click="goToHome">
            Cancel
        </a>
        <a class="next-cta"
           v-if="step === 2"
           @click="step++">
            Next
        </a>
      </div>
      ...
    </div>
  </div>
</template>
<script>
...
export default {
  name: "App",
  ...
};
</script>
<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Для элемента «Next» мы просто подключили слушатель кликов, чтобы увеличить значение step на 1.

Одна вещь, которую мы хотели бы - это позволить бы пользователю загружать изображение, если не на домашнем индексном экране (т.е. при step ! == 1 ). Чтобы пользователь не мог кликнуть на input для загрузки изображения мы свяжем свойство disabled для input type = file и будем применять его при step ! == 1:

<template>  
  <div id="app">  
    <div class="app-phone">  
      ...  
      <div class="phone-footer">  
        ...  
        <div class="upload-cta">  
          <input type="file"  
            name="file"  
            id="file"  
            class="inputfile"  
            @change="uploadImage"  
            :disabled="step !== 1"  
          />  
          <label for="file">  
            <i class="far fa-plus-square fa-lg"></i>  
          </label>  
        </div>  
      </div>  
    </div>  
  </div>  
</template>

<script>  
...  
export default {  
  name: "App",  
  ...  
};  
</script>

<style lang="scss" src="./styles/app.scss">  
// Styles from stylesheet  
</style>

input загружаемого изображения теперь будет отключен, если пользователь не находится на первом шаге. Это завершение построения второго шага нашего UI. Со всеми внесенными изменениями наш файл App.vue будет выглядеть следующим образом:

<template>
  <div id="app">
    <div class="app-phone">
      <div class="phone-header">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/vue_gram_logo_cp.png" />
        <a class="cancel-cta"
           v-if="step === 2 || step === 3" 
           @click="goToHome">
            Cancel
        </a>
        <a class="next-cta"
           v-if="step === 2"
           @click="step++">
            Next
        </a>
      </div>
      <phone-body
        :step="step"
        :posts="posts"
        :filters="filters"
        :image="image"
        :selectedFilter="selectedFilter"
        v-model="caption"
      />
      <div class="phone-footer">
       <div class="home-cta" @click="goToHome">
        <i class="fas fa-home fa-lg"></i>
       </div>
       <div class="upload-cta">
          <input type="file"
            name="file"
            id="file"
            class="inputfile"
            @change="uploadImage"
            :disabled="step !== 1"
          />
          <label for="file">
            <i class="far fa-plus-square fa-lg"></i>
          </label>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import PhoneBody from "./components/PhoneBody";

import posts from "./data/posts";
import filters from "./data/filters";

import EventBus from "./event-bus.js";

export default {
  name: "App",
  data() {
    return {
      step: 1,
      posts,
      filters,
      image: "",
      selectedFilter: "",
      caption: ""
    };
  },
  created() {
    EventBus.$on("filter-selected", evt => {
      this.selectedFilter = evt.filter;
    });
  },
  methods: {
    uploadImage(evt) {
      const files = evt.target.files;
      if (!files.length) return;

      const reader = new FileReader();
      reader.readAsDataURL(files[0]);
      reader.onload = evt => {
        this.image = evt.target.result;
        this.step = 2;
      };

      // To enable reuploading of same files in Chrome
      document.querySelector("#file").value = "";
    },
    goToHome() {
      this.image = "";
      this.selectedFilter = "";
      this.caption = "";
      this.step = 1;
    }
  },
  components: {
    "phone-body": PhoneBody
  }
};
</script>

<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Если мы протестируем наше приложение, мы сможем загрузить изображение и выбрать наш фильтр выбора.

Все, что нам осталось сделать, это захватить введенный пользователем текст на третьем шаге и это позволит пользователю поделиться созданным постом!

4) Завершение процесса оправки сообщения

Захват контента

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

В шаблоне PhoneBody.vue мы представим новый элемент, который отображается только тогда, когда шаг равен 3. Этот элемент будет содержать элемент предварительного просмотра div class = “selected-image”, как и второй шаг, но теперь также будет содержать поле textarea:

<template>
  <div class="phone-body">
    <div v-if="step === 1" class="feed">
      ...
    </div>
    <div v-if="step === 2">
      ...
    </div>
    <div v-if="step === 3">
      <div class="selected-image"
        :class="selectedFilter"
        :style="{ backgroundImage: 'url(' + image + ')' }">
      </div>
      <div class="caption-container">
        <textarea class="caption-input"
          placeholder="Write a caption..."
          type="text">
        </textarea>
      </div>
    </div>
  </div>
</template>
<script>
...
export default {
  name: "PhoneBody",
  ...
};
</script>
<style lang="scss" src="../styles/phone-body.scss">
// Styles from stylesheet
</style>

Нам нужно получить значение, которое вводит пользователь и привязать его к caption в родительском App компоненте. Чтобы это произошло, мы будем передавать параметр caption в компонент PhoneBody и использовать директиву v-model в textarea для создания двухсторонней привязки данных. Хотя обычно это работает, в этом случае это не сработает.

Использование v-модели в PhoneBody для непосредственной привязки к полю caption не будет работать, потому что дочерний компонент PhoneBody, напрямую будет мутировать значение данных родительского компонента (App). Это известная плохая практика, что приведёт к генерации Vue предупреждения в консоли.

Мы можем избежать этого, объявив более специфичную двухстороннюю привязку данных. Во-первых, мы уже объявили атрибут v-model, где компонент PhoneBody рендерится в шаблоне App.vue:

<template>
  <div id="app">
    <div class="app-phone">
      <div class="phone-header">
        ...
      </div>
      <phone-body
        :step="step"
        :posts="posts"
        :filters="filters"
        :image="image"
        :selectedFilter="selectedFilter"
        v-model="caption"
      />
      <div class="phone-footer">
        ...
      </div>
    </div>
  </div>
</template>
<script>
...
export default {
  name: "App",
  ...
};
</script>
<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

v-model по сути связывает параметр value и создает обработчик ввода, который изменяет параметр value. Эта штука в документации Vue объясняет это еще немного - Использование v-model на компонентах.

В PhoneBody мы можем теперь объявить значение, которое будет передаваться вниз и привязать его к полю textarea поста. Наш обновлённый файл PhoneBody.vue станет выглядеть следующим образом:

<template>
  <div class="phone-body">
    ...
    <div v-if="step === 3">
      ...
      <div class="caption-container">
        <textarea class="caption-input"
          placeholder="Write a caption..."
          type="text"
          :value="value">
        </textarea>
      </div>
    </div>
  </div>
</template>
<script>
...
export default {
  name: "PhoneBody",
  props: {
    step: Number,
    posts: Array,
    filters: Array,
    image: String,
    selectedFilter: String,
    value: String
  },
  ...
};
</script>
<style lang="scss" src="../styles/phone-body.scss">
// Styles from stylesheet
</style>

Чтобы захватить в режиме реального времени ввод пользователя, нам нужно будет выпустить новое кастомное input событие и передать новое значение ввода.

<template>
  <div class="phone-body">
    ...
    <div v-if="step === 3">
      ...
      <div class="caption-container">
        <textarea class="caption-input"
          placeholder="Write a caption..."
          type="text"
          :value="value"
          @input="$emit('input', $event.target.value)">
        </textarea>
      </div>
    </div>
  </div>
</template>
<script>
...
export default {
  name: "PhoneBody",
  props: {
    step: Number,
    posts: Array,
    filters: Array,
    image: String,
    selectedFilter: String,
    value: String
  },
  ...
};
</script>
<style lang="scss" src="./styles/phone-body.scss">
// Styles from stylesheet
</style>

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

Теперь, когда пользователь вводит подпись в PhoneBody, свойство данных caption в приложении обновляется с помощью настраиваемого input события, которое запускается.

Теперь файл PhoneBody.vue будет выглядеть следующим образом:

<template>
  <div class="phone-body">
    <div v-if="step === 1" class="feed">
      <vuegram-post v-for="(post,i) in posts"
        :post="post"
        :key="i">
      </vuegram-post>
    </div>
    <div v-if="step === 2">
      <div class="selected-image"
        :class="selectedFilter"
        :style="{ backgroundImage: 'url(' + image + ')' }">
      </div>
      <div class="filter-container">
        <filter-type v-for="(filter,i) in filters"
          :filter="filter"
          :image="image"
          :key="i">
        </filter-type>
      </div>
    </div>
    <div v-if="step === 3">
      <div class="selected-image"
        :class="selectedFilter"
        :style="{ backgroundImage: 'url(' + image + ')' }">
      </div>
      <div class="caption-container">
        <textarea class="caption-input"
          placeholder="Write a caption..."
          type="text"
          :value="value"
          @input="$emit('input', $event.target.value)">
        </textarea>
      </div>
    </div>
  </div>
</template>

<script>
import VuegramPost from "./VuegramPost";
import FilterType from "./FilterType";

export default {
  name: "PhoneBody",
  props: {
    step: Number,
    posts: Array,
    filters: Array,
    image: String,
    selectedFilter: String,
    value: String
  },
  components: {
    "vuegram-post": VuegramPost,
    "filter-type": FilterType
  }
};
</script>

<style lang="scss" src="../styles/phone-body.scss">
// Styles from stylesheet
</style>

Публикация поста

Чтобы завершить отправку поста, мы должны предоставить пользователю возможность публиковать пост по клику на ссылку «Share» в шапке:

Ссылка на Share должна присутствовать только в том случае, если пользователь находится на 3м (последнем) шаге. Поэтому мы добавим этот элемент условно в заголовок раздела App:

<template>
  <div id="app">
    <div class="app-phone">
      <div class="phone-header">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/vue_gram_logo_cp.png"/>
        <a class="cancel-cta"
           v-if="step === 2 || step === 3"
           @click="goToHome">
            Cancel
        </a>
        <a class="next-cta"
           v-if="step === 2"
           @click="step++">
            Next
        </a>
        <a class="next-cta"
           v-if="step === 3"
           @click="sharePost">
            Share
        </a>
      </div>
      ...
    </div>
  </div>
</template>
<script>
...
export default {
  name: "App",
  ...
};
</script>
<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Мы подключили слушатель кликов, который вызывает метод sharePost() при нажатии ссылки «Share». Этот метод будет делать три вещи:

Он подготовит post объект, который будет содержать данные, предоставленные пользователем
В дополнение к тому, что пользователь отправил, подготовленный post объект будет содержать информацию про имя пользователя username и изображение постера userImage. Для простоты мы просто зададим имя пользователя fullstack_vue, а userImage как изображение логотипа Vue.

Он будет пушить новый post объект в массив posts приложения
Чтобы пушнуть новый элемент в начало массива, мы будем использовать нативный метод Array.unshift().

Он сбросит всю информацию и вернет пользователя на главную страницу
У нас уже есть метод goToHome(), который мы, наконец, будем использовать для сброса информации о пользователе и установки значения шага step назад в 1.

Со всем этим мы укажем этот метод sharePost() в свойстве methods() приложения:

<template>
  <div id="app">
    ...
  </div>
</template>
<script>
...
export default {
  name: "App",
  ...
  methods: {
    ...
    sharePost() {
      const post = {
        username: "fullstack_vue",
        userImage:
          "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/vue_lg_bg.png",
        postImage: this.image,
        likes: 0,
        caption: this.caption,
        filter: this.filterType
      };
      this.posts.unshift(post);
      this.goToHome();
    }
  },
  ...
};
</script>
<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Мы не будем делать больше обновлений для компонента App, теперь App.vue будет выглядеть так на конечном этапе:

<template>
  <div id="app">
    <div class="app-phone">
      <div class="phone-header">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/vue_gram_logo_cp.png" />
        <a class="cancel-cta"
           v-if="step === 2 || step === 3" 
           @click="goToHome">
            Cancel
        </a>
        <a class="next-cta"
           v-if="step === 2"
           @click="step++">
            Next
        </a>
        <a class="next-cta"
           v-if="step === 3"
           @click="sharePost">
            Share
        </a>
      </div>
      <phone-body
        :step="step"
        :posts="posts"
        :filters="filters"
        :image="image"
        :selectedFilter="selectedFilter"
        v-model="caption"
      />
      <div class="phone-footer">
       <div class="home-cta" @click="goToHome">
        <i class="fas fa-home fa-lg"></i>
       </div>
       <div class="upload-cta">
          <input type="file"
            name="file"
            id="file"
            class="inputfile"
            @change="uploadImage"
            :disabled="step !== 1"
          />
          <label for="file">
            <i class="far fa-plus-square fa-lg"></i>
          </label>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import PhoneBody from "./components/PhoneBody";

import posts from "./data/posts";
import filters from "./data/filters";

import EventBus from "./event-bus.js";

export default {
  name: "App",
  data() {
    return {
      step: 1,
      posts,
      filters,
      image: "",
      selectedFilter: "",
      caption: ""
    };
  },
  created() {
    EventBus.$on("filter-selected", evt => {
      this.selectedFilter = evt.filter;
    });
  },
  methods: {
    uploadImage(evt) {
      const files = evt.target.files;
      if (!files.length) return;

      const reader = new FileReader();
      reader.readAsDataURL(files[0]);
      reader.onload = evt => {
        this.image = evt.target.result;
        this.step = 2;
      };

      // To enable reuploading of same files in Chrome
      document.querySelector("#file").value = "";
    },
    sharePost() {
      const post = {
        username: "fullstack_vue",
        userImage:
          "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1211695/vue_lg_bg.png",
        postImage: this.image,
        likes: 0,
        caption: this.caption,
        filter: this.filterType
      };
      this.posts.unshift(post);
      this.goToHome();
    },
    goToHome() {
      this.image = "";
      this.selectedFilter = "";
      this.caption = "";
      this.step = 1;
    }
  },
  components: {
    "phone-body": PhoneBody
  }
};
</script>

<style lang="scss" src="./styles/app.scss">
// Styles from stylesheet
</style>

Теперь мы можем пройти весь процесс отправки поста!

5) Перетягивание-прокрутка

UI нашего приложения почти закончен. Мы добавим одну новую дополнительную функцию для завершения урока.

Как вы могли заметить, нам пришлось прокручивать ленту (и список фильтров) для навигации по списку элементов. В традиционных мобильных приложениях мы часто можем перемещаться по приложению перетягиванием экрана. Чтобы включить аналогичную функцию с перетаскиванием в нашем приложении, мы будем использовать vue-dragscroll библиотеку.

Для включения новой библиотеки в наше приложение нам нужно установить её с помощью yarn или npm (или получить к ней доступ через CDN):

npm install vue-dragscroll --save

В нашей песочнице уже есть библиотека vue-dragscroll как зависимость.

С доступной библиотекой мы сначала зарегистрируем ее в нашем приложении. В файле index.js мы импортируем библиотеку vue-dragscroll и укажем Vue.use(), чтобы использовать плагин в нашем модульном приложении:

import Vue from "vue";
import App from "./App";
import VueDragscroll from "vue-dragscroll";
Vue.use(VueDragscroll);
/* eslint-disable no-new */
new Vue({
  el: "#app",
  render: h => h(App)
});

С установленным плагином мы можем использовать директивы v-dragscroll в наших шаблонах. Мы хотим, чтобы лента и списки фильтров приложения можно было перетаскивать вдоль осей Y и X соответственно:

В шаблоне PhoneBody.vue мы укажем возможность вертикального перетаскивания в ленте, добавив v-dragscroll.y в элемент ленты. Аналогично, мы добавим v-dragscroll.x в контейнер фильтров, который содержит список типов фильтров на 2 шаге.

<template>
  <div class="phone-body">
    <div v-if="step === 1" class="feed" v-dragscroll.y>
      <instagram-post v-for="(post,i) in posts"
        :post="post"
        :key="i">
      </instagram-post>
    </div>
    <div v-if="step === 2">
      <div class="selected-image"
        :class="selectedFilter"
        :style="{ backgroundImage: 'url(' + image + ')' }">
      </div>
      <div class="filter-container" v-dragscroll.x>
        <filter-type v-for="(filter,i) in filters"
          :filter="filter"
          :image="image"
          :key="i">
        </filter-type>
      </div>
    </div>
    ...
  </div>
</template>
<script>
...
export default {
  name: "PhoneBody",
  ...
};
</script>
<style lang="scss" src="./styles/phone-body.scss">
// Styles from stylesheet
</style>

И наконец-то наше приложение завершено! Со всем реализованным мы можем перетаскивать посты в ленте, загружать изображение, выбирать фильтр, добавлять текст к фото и публиковать посты!

Вывод

Без учета стилизации нашего приложения с помощью CSS мы прошли через урок создание полного UI, который имитирует процес отправки фото и сообщений в Instagram на мобильном устройстве. К тому же мы воспользовались библиотекой CSSGram для добавления фильтров как в Instagram, и vue-dragscroll для добавления функциональности прокрутки и перетаскивания постов.

Если вы застряли в любой момент и/или имеете дополнительные вопросы, вы более чем можете оставить комментарий или сообщение прямо мне! Обратная связь всегда приветствуется!