Создание приложения с галереей изображений с бесконечной прокруткой и API Pixabay

Добавить бесконечную прокрутку в приложение Vue.js очень просто. Уже есть библиотеки для создания красивой галереи изображений. С Vue.js создание приложения для галереи изображений - приятное занятие.

Мы будем использовать Vue Material, чтобы приложение выглядело привлекательно, и Pixabay API.

В нашем приложении мы предоставим страницы для поиска изображений и видео, а на главной странице будет бесконечная галерея изображений с прокруткой, чтобы пользователи могли просматривать самые популярные изображения из API Pixabay.

Для начала мы устанавливаем Vue CLI, запустив npm i @vue/cli. Затем мы создаем проект, запустив vue create pixabay-app.

При запросе параметров мы выбираем настраиваемые параметры и включаем Babel, Vuex, Vue Router и препроцессор CSS. Все они нам нужны, поскольку мы создаем единую страницу с общим состоянием между компонентами, а препроцессор CSS сокращает повторение CSS.

Затем нам нужно установить библиотеки, необходимые для работы нашего приложения. Запустите npm i axios querystring vue-material vee-validate vue-infinite-scroll, чтобы установить нужные нам пакеты.

axios - наш HTTP-клиент, querystring - это пакет для сообщения объектов в строке запроса, vue-material предоставляет нашему приложению элементы материального дизайна, чтобы оно выглядело привлекательно.

vee-validate - это библиотека проверки формы. vue-infinite-scroll - это библиотека с бесконечной прокруткой. С помощью этой библиотеки легко добавить бесконечную прокрутку.

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

В папке components мы создаем файл с именем Results.vue и добавляем следующее:

<template>
  <div class="center">
    <div class="results">
      <md-card v-for="r in searchResults" :key="r.id">
        <md-card-media v-if="type == 'image'">
          <img :src="r.previewURL" class="image" />
        </md-card-media>
        <md-card-media v-if="type == 'video'">
          <video class="image">
            <source :src="r.videos.tiny.url" type="video/mp4" />
          </video>
        </md-card-media>
      </md-card>
    </div>
  </div>
</template>
<script>
export default {
  name: "results",
  props: {
    type: String
  },
  computed: {
    searchResults() {
      return this.$store.state.searchResults;
    }
  },
  data() {
    return {};
  }
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
.md-card {
  width: 30vw;
  margin: 4px;
  display: inline-block;
  vertical-align: top;
}
</style>

В этом файле мы получаем данные из хранилища Vuex и затем отображаем их пользователю. this.$store предоставляется Vuex и предоставляет состояние с помощью свойства state.

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

Затем мы добавляем миксин для выполнения запросов. Для этого мы добавляем папку mixins и файл с именем photosMixin.js, а также добавляем следующее:

const axios = require('axios');
const querystring = require('querystring');
const apiUrl = 'https://pixabay.com/api';
const apikey = 'Pixabay api key';
export const photosMixin = {
    methods: {
      getPhotos(page = 1) {
            const params = {
                page,
                key: apikey,
                per_page: 21
            }
            const queryString = querystring.stringify(params);
            return axios.get(`${apiUrl}/?${queryString}`);
        },
searchPhoto(data) {
            let params = Object.assign({}, data);
            params['key'] = apikey;
            params['per_page'] = 21;
            Object.keys(params).forEach(key => {
                if (!params[key]) {
                    delete params[key];
                }
            })
            const queryString = querystring.stringify(params);
            return axios.get(`${apiUrl}/?${queryString}`);
        },
searchVideo(data) {
            let params = Object.assign({}, data);
            params['key'] = apikey;
            params['per_page'] = 21;
            Object.keys(params).forEach(key => {
                if (!params[key]) {
                    delete params[key];
                }
            })
            const queryString = querystring.stringify(params);
            return axios.get(`${apiUrl}/videos/?${queryString}`);
        }
    }
}

Здесь мы получаем изображения и видео из API Pixabay. Мы отправляем данные поиска, переданные в строку запроса, с помощью пакета querystring.

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

Мы создаем файл с именем Home.vue в папке views, если он еще не существует, и помещаем следующее:

<template>
  <div class="home">
    <div class="center">
      <h1>Home</h1>
      <div
        v-infinite-scroll="loadMore"
        infinite-scroll-disabled="busy"
        infinite-scroll-distance="10"
      >
        <md-card v-for="p in photos" :key="p.id">
          <md-card-media>
            <img :src="p.previewURL" class="image" />
          </md-card-media>
        </md-card>
      </div>
    </div>
  </div>
</template>
<script>
// @ is an alias to /src
import Results from "@/components/Results.vue";
import { photosMixin } from "@/mixins/photosMixin";
export default {
  name: "home",
  components: {
    Results
  },
  data() {
    return {
      photos: [],
      page: 1
    };
  },
  mixins: [photosMixin],
  beforeMount() {
    this.getAllPhotos();
  },
  methods: {
    async getAllPhotos() {
      const response = await this.getPhotos();
      this.photos = response.data.hits;
    },
async loadMore() {
      this.page++;
      const response = await this.getPhotos(this.page);
      this.photos = this.photos.concat(response.data.hits);
    }
  }
};
</script>
<style lang="scss" scoped>
.md-card {
  width: 30vw;
  margin: 4px;
  display: inline-block;
  vertical-align: top;
}
.home {
  margin: 0 auto;
}
</style>

Свойство v-infinite-scroll - это обработчик событий, предоставляемый vue-infinite-scroll, чтобы мы могли что-то делать, когда пользователь прокручивает страницу вниз.

В этом случае мы вызываем функцию loadMore, чтобы загрузить данные со следующей страницы и добавить их в массив this.photos. Мы отображаем изображения в виде карточек.

infinite-scroll-distance=”10" означает, что мы запускаем обработчик, определенный в v-infinite-scroll, когда пользователь прокручивает 90 процентов текущей страницы.

Далее мы создаем страницу поиска изображений. В папке views мы создаем файл с именем ImageSearch.vue и добавляем следующее:

<template>
  <div class="imagesearch">
    <div class="center">
      <h1>Image Search</h1>
    </div>
    <form @submit="search" novalidate>
      <md-field :class="{ 'md-invalid': errors.has('q') }">
        <label for="q">Keyword</label>
        <md-input type="text" name="q" v-model="searchData.q" v-validate="'required'"></md-input>
        <span class="md-error" v-if="errors.has('q')">{{errors.first('q')}}</span>
      </md-field>
<md-field :class="{ 'md-invalid': errors.has('minWidth') }">
        <label for="minWidth">Min Width</label>
        <md-input
          type="text"
          name="minWidth"
          v-model="searchData.min_width"
          v-validate="'numeric|min_value:0'"
        ></md-input>
        <span class="md-error" v-if="errors.has('minWidth')">{{errors.first('minWidth')}}</span>
      </md-field>
<md-field :class="{ 'md-invalid': errors.has('minHeight') }">
        <label for="minHeight">Min Height</label>
        <md-input
          type="text"
          name="minHeight"
          v-model="searchData.min_height"
          v-validate="'numeric|min_value:0'"
        ></md-input>
        <span class="md-error" v-if="errors.has('minHeight')">{{errors.first('minHeight')}}</span>
      </md-field>
<md-field>
        <label for="movie">Colors</label>
        <md-select v-model="searchData.colors" name="colors">
          <md-option :value="c" v-for="c in colorChoices" :key="c">{{c}}</md-option>
        </md-select>
      </md-field>
<md-button class="md-raised" type="submit">Search</md-button>
    </form>
    <Results type='image' />
  </div>
</template>
<script>
// @ is an alias to /src
import Results from "@/components/Results.vue";
import { photosMixin } from "@/mixins/photosMixin";
export default {
  name: "home",
  components: {
    Results
  },
  data() {
    return {
      photos: [],
      searchData: {},
      colorChoices: [
        "grayscale",
        "transparent",
        "red",
        "orange",
        "yellow",
        "green",
        "turquoise",
        "blue",
        "lilac",
        "pink",
        "white",
        "gray",
        "black",
        "brown"
      ]
    };
  },
  mixins: [photosMixin],
  beforeMount() {
    this.$store.commit("setSearchResults", []);
  },
  computed: {
    isFormDirty() {
      return Object.keys(this.fields).some(key => this.fields[key].dirty);
    }
  },
  methods: {
    async search(evt) {
      evt.preventDefault();
      if (!this.isFormDirty || this.errors.items.length > 0) {
        return;
      }
      const response = await this.searchPhoto(this.searchData);
      this.photos = response.data.hits;
      this.$store.commit("setSearchResults", response.data.hits);
    }
  }
};
</script>

На этой странице представлена ​​форма, в которой пользователь может ввести параметры поиска, такие как ключевое слово, размеры изображения и цвета. Мы проверяем правильность данных для каждого поля с помощью свойства v-validate, предоставляемого пакетом vee-validate.

Итак, для minWidth и minHeight мы убеждаемся, что это неотрицательные числа. Если это не так, мы выводим сообщение об ошибке. Мы также не разрешим отправку, если данные формы недействительны с проверкой this.errors.items.length > 0, которая также предоставляется vee-validate.

Если все верно, мы вызываем функцию this.searchPhotos, предоставленную нашим photoMixin, и устанавливаем результат при возврате в магазин с помощью функции this.$store.commit, предоставленной магазином.

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

Точно так же для страницы поиска видео мы создаем новый файл с именем VideoSearch.vue в папке views. Ставим следующее:

<template>
  <div class="videosearch">
    <div class="center">
      <h1>Video Search</h1>
    </div>
    <form @submit="search" novalidate>
      <md-field :class="{ 'md-invalid': errors.has('q') }">
        <label for="q">Keyword</label>
        <md-input type="text" name="q" v-model="searchData.q" v-validate="'required'"></md-input>
        <span class="md-error" v-if="errors.has('q')">{{errors.first('q')}}</span>
      </md-field>
<md-field :class="{ 'md-invalid': errors.has('minWidth') }">
        <label for="minWidth">Min Width</label>
        <md-input
          type="text"
          name="minWidth"
          v-model="searchData.min_width"
          v-validate="'numeric|min_value:0'"
        ></md-input>
        <span class="md-error" v-if="errors.has('minWidth')">{{errors.first('minWidth')}}</span>
      </md-field>
<md-field :class="{ 'md-invalid': errors.has('minHeight') }">
        <label for="minHeight">Min Height</label>
        <md-input
          type="text"
          name="minHeight"
          v-model="searchData.min_height"
          v-validate="'numeric|min_value:0'"
        ></md-input>
        <span class="md-error" v-if="errors.has('minHeight')">{{errors.first('minHeight')}}</span>
      </md-field>
<md-field>
        <label for="categories">Categories</label>
        <md-select v-model="searchData.category" name="categories">
          <md-option :value="c" v-for="c in categories" :key="c">{{c}}</md-option>
        </md-select>
      </md-field>
<md-field>
        <label for="type">Type</label>
        <md-select v-model="searchData.video_type" name="type">
          <md-option :value="v" v-for="v in videoTypes" :key="v">{{v}}</md-option>
        </md-select>
      </md-field>
<md-button class="md-raised" type="submit">Search</md-button>
    </form>
    <Results type="video" />
  </div>
</template>
<script>
import Results from "@/components/Results.vue";
import { photosMixin } from "@/mixins/photosMixin";
export default {
  name: "home",
  components: {
    Results
  },
  data() {
    return {
      photos: [],
      searchData: {},
      videoTypes: ["all", "film", "animation"],
      categories: `
       fashion, nature, backgrounds,
       science, education, people, feelings,
       religion, health, places, animals, industry,
       food, computer, sports, transportation,
       travel, buildings, business, music
      `
        .replace(/ /g, "")
        .split(",")
    };
  },
  mixins: [photosMixin],
  beforeMount() {
    this.$store.commit("setSearchResults", []);
  },
  computed: {
    isFormDirty() {
      return Object.keys(this.fields).some(key => this.fields[key].dirty);
    }
  },
  methods: {
    async search(evt) {
      evt.preventDefault();
      if (!this.isFormDirty || this.errors.items.length > 0) {
        return;
      }
      const response = await this.searchVideo(this.searchData);
      this.photos = response.data.hits;
      this.$store.commit("setSearchResults", response.data.hits);
    }
  }
};
</script>

Мы позволяем пользователям искать по ключевым словам, типу, размерам и категории.

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

В App.vue мы добавляем верхнюю панель и левое меню для навигации. Мы заменяем существующий код следующим:

<template>
  <div id="app">
    <md-toolbar>
      <md-button class="md-icon-button" @click="showNavigation = true">
        <md-icon>menu</md-icon>
      </md-button>
      <h3 class="md-title">Pixabay App</h3>
    </md-toolbar>
    <md-drawer :md-active.sync="showNavigation" md-swipeable>
      <md-toolbar class="md-transparent" md-elevation="0">
        <span class="md-title">Pixabay App</span>
      </md-toolbar>
<md-list>
        <md-list-item>
          <router-link to="/">
            <span class="md-list-item-text">Home</span>
          </router-link>
        </md-list-item>
<md-list-item>
          <router-link to="/imagesearch">
            <span class="md-list-item-text">Image Search</span>
          </router-link>
        </md-list-item>
<md-list-item>
          <router-link to="/videosearch">
            <span class="md-list-item-text">Video Search</span>
          </router-link>
        </md-list-item>
      </md-list>
    </md-drawer>
<router-view />
  </div>
</template>
<script>
export default {
  name: "app",
  data: () => {
    return {
      showNavigation: false
    };
  }
};
</script>
<style>
.center {
  text-align: center;
}
form {
  width: 95vw;
  margin: 0 auto;
}
.md-toolbar.md-theme-default {
  background: #009688 !important;
  height: 60px;
}
.md-title,
.md-toolbar.md-theme-default .md-icon {
  color: #fff !important;
}
</style>

md-list содержит ссылки на наши страницы, и у нас есть флаг showNavigation, чтобы сохранить состояние меню и позволить нам переключать меню.

В main.js мы включаем библиотеки, которые использовали в этом приложении, чтобы оно запускалось при запуске приложения:

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import VueMaterial from 'vue-material';
import VeeValidate from 'vee-validate';
import 'vue-material/dist/vue-material.min.css'
import 'vue-material/dist/theme/default.css'
import infiniteScroll from 'vue-infinite-scroll'
Vue.use(infiniteScroll)
Vue.use(VueMaterial);
Vue.use(VeeValidate);
Vue.config.productionTip = false
new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

В router.js мы добавляем наши маршруты, чтобы пользователи могли видеть приложение, когда они вводят URL-адрес или щелкают ссылки в меню:

import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue';
import Search from './views/Search.vue';
Vue.use(Router)
export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/search',
      name: 'search',
      component: Search
    }
  ]
})

В store.js мы помещаем:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {
    searchResults: []
  },
  mutations: {
    setSearchResults(state, payload) {
      state.searchResults = payload;
    }
  },
  actions: {
}
})

Чтобы результаты можно было установить со страниц и отобразить в компоненте Results. Функция this.$store.commit устанавливает данные, а свойство this.$store.state извлекает состояние.

Когда мы запускаем npm run serve, мы получаем: