Создание приложения, отображающего данные из API New York Times.

В Vue.js. есть отличная поддержка Материального дизайна. Одна из библиотек, доступных для Vue.js, - Vuetify. Его легко включить в приложение Vue.js, и результат будет привлекательным для пользователей.

В этой части мы создадим приложение, которое отображает данные из New York Times API. Вы можете зарегистрироваться для получения ключа API на https://developer.nytimes.com/. После этого мы можем приступить к созданию приложения.

Чтобы начать сборку приложения, мы должны установить Vue CLI. Мы делаем это, запустив:

npm install -g @vue/cli

Для запуска Vue CLI требуется Node.js 8.9 или новее. Мне не удалось заставить Vue CLI работать с версией Node.js. для Windows. У Ubuntu не было проблем с запуском Vue CLI.

Затем запускаем:

vue create vuetify-nyt-app

Создать папку проекта и создать файлы. В мастере вместо использования параметров по умолчанию мы выбираем Выбрать функции вручную. Мы выбираем Babel, Router и Vuex из списка опций, нажимая пробел на каждой. Если они зеленые, это означает, что они выбраны.

Теперь нам нужно установить несколько библиотек. Нам нужно установить HTTP-клиент, библиотеку для форматирования дат, одну для генерации GET строк запроса из объектов, а другую - для проверки формы.

Также нам необходимо установить саму библиотеку Vue Material. Мы делаем это, запустив:

npm i axios moment querystring vee-validate

axios - наш HTTP-клиент, moment - для управления датами, querystring - для генерации строк запроса из объектов, а vee-validate - дополнительный пакет для Vue.js для проверки.

Затем нам нужно добавить шаблон для vuetify. Мы делаем это, запустив vue add vuetify. Это добавляет библиотеку и ссылки на нее в нашем приложении в соответствующие места в нашем коде.

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

Сначала мы создаем некоторые компоненты. В папке views создаем Home.vue и Search.vue. Это файлы кода для наших страниц. Затем создайте папку mixins и файл с именем nytMixin.js.

Миксины - это фрагменты кода, которые можно включать непосредственно в компоненты Vue.js и использовать, как если бы они были непосредственно в компоненте. Затем мы добавляем несколько фильтров.

Фильтры - это код Vue.js, который отображает одно на другое. Создаем папку filters и добавляем capitalize.js и formatDate.js.

В папке components мы создаем файл с именем SearchResults.vue. Папка components содержит компоненты Vue.js, которые не являются страницами.

Чтобы упростить и упорядочить передачу данных между компонентами, мы используем Vuex для управления состоянием. Поскольку мы выбрали Vuex при запуске vue create, у нас должен быть store.js в папке нашего проекта. Если нет, создайте его.

В 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: {
}
})

В объекте state хранится состояние. Объект mutations - это то место, где мы можем управлять своим состоянием.

Когда мы вызываем this.$store.commit(“setSearchResults”, searchResults) в нашем коде, учитывая, что searchResults определено, state.searchResults будет установлено в searchResults.

Затем мы можем получить результат, используя this.$store.state.searchResults.

Нам нужно добавить шаблонный код в наше приложение. Сначала мы добавляем наш фильтр. В capitalize.js мы помещаем:

export const capitalize = (str) => {
    if (typeof str == 'string') {
        if (str == 'realestate') {
            return 'Real Estate';
        }
        if (str == 'sundayreview') {
            return 'Sunday Review';
        }
if (str == 'tmagazine') {
            return 'T Magazine';
        }
        return `${str[0].toUpperCase()}${str.slice(1)}`;
    }
}

Это позволяет нам использовать заглавные буквы в названиях разделов New York Times, перечисленных на страницах для разработчиков New York Times. Затем в formatDate.js мы помещаем:

import * as moment from 'moment';
export const formatDate = (date) => {
    if (date) {
        return moment(date).format('YYYY-MM-DD hh:mm A');
    }
}

Чтобы отформатировать наши даты в удобочитаемый формат.

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

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import { formatDate } from './filters/formatDate';
import { capitalize } from './filters/capitalize';
import VeeValidate from 'vee-validate';
import Vuetify from 'vuetify/lib'
import vuetify from './plugins/vuetify';
import '@mdi/font/css/materialdesignicons.css'
Vue.config.productionTip = false;
Vue.use(VeeValidate);
Vue.use(Vuetify);
Vue.filter('formatDate', formatDate);
Vue.filter('capitalize', capitalize);
new Vue({
  router,
  store,
  vuetify,
  render: h => h(App)
}).$mount('#app')

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

Мы вызываем Vue.filter для наших функций фильтрации, чтобы мы могли использовать их в наших шаблонах, добавляя канал и имя фильтра справа от нашей переменной.

Затем в router.js мы помещаем:

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
    }
  ]
})

Чтобы мы могли переходить на страницы, когда вводим перечисленные URL-адреса.

mode: ‘history’ означает, что у нас не будет знака решетки между базовым URL и нашими маршрутами.

Если мы развернем наше приложение, нам нужно настроить наш веб-сервер так, чтобы все запросы перенаправлялись на index.html, чтобы у нас не было ошибок при перезагрузке приложения.

Например, в Apache мы делаем:

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

А в NGINX мы помещаем:

location / {
  try_files $uri $uri/ /index.html;
}

См. Документацию к вашему веб-серверу, чтобы узнать, как сделать то же самое на вашем веб-сервере.

Теперь мы пишем код для наших компонентов. В SearchResult.vue мы помещаем:

<template>
  <v-container>
    <v-card v-for="s in searchResults" :key="s.id" class="mx-auto">
      <v-card-title>{{s.headline.main}}</v-card-title>
<v-list-item>
        <v-list-item-content>Date: {{s.pub_date | formatDate}}</v-list-item-content>
      </v-list-item>
      <v-list-item>
        <v-list-item-content>
          <a :href="s.web_url">Link</a>
        </v-list-item-content>
      </v-list-item>
      <v-list-item v-if="s.byline.original">
        <v-list-item-content>{{s.byline.original}}</v-list-item-content>
      </v-list-item>
      <v-list-item>
        <v-list-item-content>{{s.lead_paragraph}}</v-list-item-content>
      </v-list-item>
      <v-list-item>
        <v-list-item-content>{{s.snippet}}</v-list-item-content>
      </v-list-item>
    </v-card>
  </v-container>
</template>
<script>
export default {
  computed: {
    searchResults() {
      return this.$store.state.searchResults;
    }
  }
};
</script>
<style scoped>
.title {
  margin: 0 15px !important;
}
#search-results {
  margin: 0 auto;
  width: 95vw;
}
</style>

Здесь мы получаем результаты поиска из магазина Vuex и отображаем их.

Мы возвращаем this.$store.state.searchResults в функции свойства computed в нашем приложении, поэтому результаты поиска будут автоматически обновляться при обновлении состояния searchResults магазина.

md-card - карточный виджет для отображения данных в поле. v-for предназначен для зацикливания записей массива и отображения всего. md-list - виджет списка для аккуратного отображения элементов в списке на странице. {{s.pub_date | formatDate}} - это то место, где применяется наш formatDate фильтр.

Далее мы пишем наш миксин. Мы добавим код для наших HTTP-вызовов в наш миксин.

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

const axios = require('axios');
const querystring = require('querystring');
const apiUrl = 'https://api.nytimes.com/svc';
const apikey = 'your api key';
export const nytMixin = {
    methods: {
        getArticles(section) {
            return axios.get(`${apiUrl}/topstories/v2/${section}.json?api-key=${apikey}`);
        },
searchArticles(data) {
            let params = Object.assign({}, data);
            params['api-key'] = apikey;
            Object.keys(params).forEach(key => {
                if (!params[key]) {
                    delete params[key];
                }
            })
            const queryString = querystring.stringify(params);
            return axios.get(`${apiUrl}/search/v2/articlesearch.json?${queryString}`);
        }
    }
}

Мы возвращаем обещания для HTTP-запросов на получение статей в каждой функции. В функции searchArticles мы сообщаем объекту, который передаем, в строку запроса, которую мы передаем в наш запрос.

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

Object.keys(params).forEach(key => {
  if (!params[key]) {
     delete params[key];
  }
})

В Home.vue мы помещаем:

<template>
  <div>
    <div class="text-center" id="header">
      <h1>{{selectedSection | capitalize}}</h1>
      <v-spacer></v-spacer>
      <v-menu offset-y>
        <template v-slot:activator="{ on }">
          <v-btn color="primary" dark v-on="on">Sections</v-btn>
        </template>
        <v-list>
          <v-list-item v-for="(s, index) in sections" :key="index" @click="selectSection(s)">
            <v-list-item-title>{{ s | capitalize}}</v-list-item-title>
          </v-list-item>
        </v-list>
      </v-menu>
      <v-spacer></v-spacer>
      <v-spacer></v-spacer>
    </div>
    <v-spacer></v-spacer>
    <v-card v-for="a in articles" :key="a.id" class="mx-auto">
      <v-card-title>{{a.title}}</v-card-title>
      <v-card-text>
        <v-list-item>
          <v-list-item-content>Date: {{a.published_date | formatDate}}</v-list-item-content>
        </v-list-item>
        <v-list-item>
          <v-list-item-content>
            <a :href="a.url">Link</a>
          </v-list-item-content>
        </v-list-item>
        <v-list-item v-if="a.byline">
          <v-list-item-content>{{a.byline}}</v-list-item-content>
        </v-list-item>
        <v-list-item>
          <v-list-item-content>{{a.abstract}}</v-list-item-content>
        </v-list-item>
        <v-list-item>
          <v-list-item-content>
            <img
              v-if="a.multimedia[a.multimedia.length - 1]"
              :src="a.multimedia[a.multimedia.length - 1].url"
              :alt="a.multimedia[a.multimedia.length - 1].caption"
              class="image"
            />
          </v-list-item-content>
        </v-list-item>
      </v-card-text>
    </v-card>
  </div>
</template>
<script>
import { nytMixin } from "../mixins/nytMixin";
export default {
  name: "home",
  mixins: [nytMixin],
  computed: {},
data() {
    return {
      selectedSection: "home",
      articles: [],
      sections: `arts, automobiles, books, business, fashion, food, health,
    home, insider, magazine, movies, national, nyregion, obituaries,
    opinion, politics, realestate, science, sports, sundayreview,
    technology, theater, tmagazine, travel, upshot, world`
        .replace(/ /g, "")
        .split(",")
    };
  },
beforeMount() {
    this.getNewsArticles(this.selectedSection);
  },
methods: {
    async getNewsArticles(section) {
      const response = await this.getArticles(section);
      this.articles = response.data.results;
    },
selectSection(section) {
      this.selectedSection = section;
      this.getNewsArticles(section);
    }
  }
};
</script>
<style scoped>
.image {
  width: 100%;
}
.title {
  color: rgba(0, 0, 0, 0.87) !important;
  margin: 0 15px !important;
}
.md-card {
  width: 95vw;
  margin: 0 auto;
}
#header {
  margin-bottom: 10px;
}
</style>

В этом компоненте страницы мы получаем статьи для выбранного раздела, по умолчанию это раздел home. У нас также есть меню для выбора раздела, который мы хотим видеть, добавив:

<v-menu offset-y>
   <template v-slot:activator="{ on }">
     <v-btn color="primary" dark v-on="on">Sections</v-btn>
   </template>
   <v-list>
      <v-list-item v-for="(s, index) in sections" :key="index" @click="selectSection(s)">
        <v-list-item-title>{{ s | capitalize}}</v-list-item-title>
      </v-list-item>
   </v-list>
</v-menu>

Обратите внимание, что мы используем ключевые слова async и await в нашем коде обещаний вместо использования then.

Он намного короче, а функциональные возможности между then, await и async эквивалентны. Однако он не поддерживается в Internet Explorer. В блоке beforeMount мы запускаем this.getNewsArticles, чтобы получать статьи по мере загрузки страницы.

Обратите внимание, что библиотека Vuetify широко использует слоты функций Vue.js. Элементы с вложенностью, такие как опора v-slot, находятся в:

<v-menu offset-y>
  <template v-slot:activator="{ on }">
     <v-btn color="primary" dark v-on="on">Sections</v-btn>
  </template>
  <v-list>
    <v-list-item v-for="(s, index) in sections" :key="index" @click="selectSection(s)">
       <v-list-item-title>{{ s | capitalize}}</v-list-item-title>
     </v-list-item>
  </v-list>
</v-menu>

См. Подробности в Руководстве по Vue.js.

В Search.vue мы помещаем:

<template>
  <div>
    <form>
      <v-text-field
        v-model="searchData.keyword"
        v-validate="'required'"
        :error-messages="errors.collect('keyword')"
        label="Keyword"
        data-vv-name="keyword"
        required
      ></v-text-field>
      <v-menu
        ref="menu"
        v-model="toggleBeginDate"
        :close-on-content-click="false"
        transition="scale-transition"
        offset-y
        full-width
        min-width="290px"
      >
        <template v-slot:activator="{ on }">
          <v-text-field
            v-model="searchData.beginDate"
            label="Begin Date"
            prepend-icon="event"
            readonly
            v-on="on"
          ></v-text-field>
        </template>
        <v-date-picker
          v-model="searchData.beginDate"
          no-title
          scrollable
          :max="new Date().toISOString()"
        >
          <v-spacer></v-spacer>
          <v-btn text color="primary" @click="toggleBeginDate = false">Cancel</v-btn>
          <v-btn
            text
            color="primary"
            @click="$refs.menu.save(searchData.beginDate); toggleBeginDate = false"
          >OK</v-btn>
        </v-date-picker>
      </v-menu>
      <v-menu
        ref="menu"
        v-model="toggleEndDate"
        :close-on-content-click="false"
        transition="scale-transition"
        offset-y
        full-width
        min-width="290px"
      >
        <template v-slot:activator="{ on }">
          <v-text-field
            v-model="searchData.endDate"
            label="End Date"
            prepend-icon="event"
            readonly
            v-on="on"
          ></v-text-field>
        </template>
        <v-date-picker
          v-model="searchData.endDate"
          no-title
          scrollable
          :max="new Date().toISOString()"
        >
          <v-spacer></v-spacer>
          <v-btn text color="primary" @click="toggleEndDate = false">Cancel</v-btn>
          <v-btn
            text
            color="primary"
            @click="$refs.menu.save(searchData.endDate); toggleEndDate = false"
          >OK</v-btn>
        </v-date-picker>
      </v-menu>
      <v-select
        v-model="searchData.sort"
        :items="sortChoices"
        label="Sort By"
        data-vv-name="sort"
        item-value="value"
        item-text="name"
      >
        <template slot="selection" slot-scope="{ item }">{{ item.name }}</template>
        <template slot="item" slot-scope="{ item }">{{ item.name }}</template>
      </v-select>
<v-btn class="mr-4" type="submit" @click="search">Search</v-btn>
    </form>
    <SearchResults />
  </div>
</template>
<script>
import { nytMixin } from "../mixins/nytMixin";
import SearchResults from "@/components/SearchResults.vue";
import * as moment from "moment";
import { capitalize } from "@/filters/capitalize";
export default {
  name: "search",
  mixins: [nytMixin],
  components: {
    SearchResults
  },
  computed: {
    isFormDirty() {
      return Object.keys(this.fields).some(key => this.fields[key].dirty);
    }
  },
  data: () => {
    return {
      searchData: {
        sort: "newest"
      },
      disabledDates: date => {
        return +date >= +new Date();
      },
      sortChoices: [
        {
          value: "newest",
          name: "Newest"
        },
        {
          value: "oldest",
          name: "Oldest"
        },
        {
          value: "relevance",
          name: "Relevance"
        }
      ],
      toggleBeginDate: false,
      toggleEndDate: false
    };
  },
  methods: {
    async search(evt) {
      evt.preventDefault();
      if (!this.isFormDirty || this.errors.items.length > 0) {
        return;
      }
      const data = {
        q: this.searchData.keyword,
        begin_date: moment(this.searchData.beginDate).format("YYYYMMDD"),
        end_date: moment(this.searchData.endDate).format("YYYYMMDD"),
        sort: this.searchData.sort
      };
      const response = await this.searchArticles(data);
      this.$store.commit("setSearchResults", response.data.response.docs);
    }
  }
};
</script>

Здесь у нас есть форма для поиска статей. У нас также есть два средства выбора даты, чтобы пометить пользователей, чтобы установить даты начала и окончания. Мы ограничиваем даты только сегодняшним днем ​​и ранее, чтобы поисковый запрос имел смысл.

В этом блоке:

<v-text-field
  v-model="searchData.keyword"
  v-validate="'required'"
  :error-messages="errors.collect('keyword')"
  label="Keyword"
  data-vv-name="keyword"
  required
></v-text-field>

Мы используем vee-validate, чтобы проверить, заполнено ли обязательное поле ключевого слова для поиска. В противном случае отобразится сообщение об ошибке и выполнение запроса будет остановлено.

Мы также вложили наш компонент SearchResults в компонент Search страницы, включив:

components: {
  SearchResults
}

Между тегом script и <SearchResults /> в шаблоне.

Наконец, мы добавляем нашу верхнюю панель и меню, помещая в App.vue следующее:

<template>
  <v-app>
    <v-navigation-drawer v-model="drawer" app>
      <v-list nav dense>
        <v-list-item-group v-model="group" active-class="deep-purple--text text--accent-4">
          <v-list-item>
            <v-list-item-title>New Yourk Times Vuetify App</v-list-item-title>
          </v-list-item>
<v-list-item>
            <v-list-item-title>
              <router-link to="/">Home</router-link>
            </v-list-item-title>
          </v-list-item>
<v-list-item>
            <v-list-item-title>
              <router-link to="/search">Search</router-link>
            </v-list-item-title>
          </v-list-item>
        </v-list-item-group>
      </v-list>
    </v-navigation-drawer>
<v-app-bar app>
      <v-toolbar-title class="headline text-uppercase">
        <v-app-bar-nav-icon @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
        <span>New York Times Vuetify App</span>
      </v-toolbar-title>
      <v-spacer></v-spacer>
    </v-app-bar>
<v-content>
      <v-container fluid>
        <router-view />
      </v-container>
    </v-content>
  </v-app>
</template>
<script>
export default {
  name: "app",
  data: () => {
    return {
      showNavigation: false,
      drawer: false,
      group: null
    };
  }
};
</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>

Если вам нужна верхняя панель с левым ящиком навигации, вы должны точно следовать структуре кода, приведенной выше.

После того, как весь код написан, мы имеем следующее: