Създаване на приложение, което показва данни от API на New York Times

Има голяма поддръжка за Material Design във Vue.js. Една от наличните библиотеки за Vue.js е Vuetify. Лесно е да се включи във вашето приложение Vue.js и резултатът е привлекателен за очите на потребителите.

В тази част ще изградим приложение, което показва данни от API на New York Times. Можете да се регистрирате за API ключ на адрес https://developer.nytimes.com/. След това можем да започнем да създаваме приложението.

За да започнем да създаваме приложението, трябва да инсталираме Vue CLI. Правим това, като изпълняваме:

npm install -g @vue/cli

За да работи Vue CLI, се изисква Node.js 8.9 или по-нова версия. Не успях да накарам Vue CLI да работи с Windows версията на Node.js. 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="/bga.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 prop, са в:

<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>

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

След като целият код е написан, имаме следното: