Тест на вакансию

Создание простого приложение на Vue.js

27 октября 2024 г.
160

Установка Vue

Выполним следующие команды:
npm create vue@latest
Проект назовём demo-furniture и далее всё пропускаем и создаём пустой проект (...blank Vue project).

Далее выполним следующие команды:
cd demo-furniture
npm install axios
npm install vue-router
npm install dompurify

Bootstrap

Для установки Bootstrap выполним команду:
npm install bootstrap @popperjs/core
Добавляем импорт Bootstrap в src/main.js:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
import './assets/css/main.css'
import './assets/css/slider.css'

createApp(App).use(router).mount('#app')

Структура приложения

Структура нашего приложения:
  • .env
  • src
    • assets
      • css
        • main.css
        • slider.css
    • components
      • FooterBar.vue
      • Loader.vue
      • NavBar.vue
      • PageViewer.vue
      • Slider.vue
    • router
      • index.js
    • services
      • api.js
    • utils
      • imageUrl.js
    • views
      • Contact.vue
      • Home.vue
      • PageDetail.vue
      • Portfolio.vue
    • App.vue
    • main.js

Файл src/App.vue

<template>
  <div id="app">
    <NavBar />
    <main class="container-fluid px-0">
      <router-view />
    </main>
    <FooterBar v-if="showFooter" />
  </div>
</template>

<script>
import NavBar from './components/NavBar.vue'
import FooterBar from './components/FooterBar.vue'

export default {
  name: 'App',
  components: {
    NavBar,
    FooterBar
  },
  computed: {
    showFooter() {
      return this.$route.name !== 'Home' && this.$route.name !== undefined
    }
  }
}
</script>

Файл .env

Отредактируем файл (.env):
VITE_API_BASE_URL=https://demo-furniture-factory.jcms.ru
VITE_ANIMATION_TYPE=zoom
Здесь VITE_ANIMATION_TYPE настройка анимации перелистывания изображений в портфолио. Доступны следующие режимы:
  • slide-next
  • fade
  • zoom
  • slide-up

Сервисы

Файл src/services/api.js

import axios from 'axios'

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL + '/api'

const api = axios.create({
  baseURL: API_BASE_URL,
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  }
})

// Интерцептор для добавления токена
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('auth_token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

// Интерцептор для обработки ошибок
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('auth_token')
      window.location.href = '/'
    }
    return Promise.reject(error)
  }
)

export const apiService = {
  // Страницы
  getPages: () => api.get('/pages'),
  getPage: (id) => api.get(`/pages/${id}`),
  // Портфолио
  getPortfolio: () => api.get('/portfolio'),
  getPortfolioItem: (id) => api.get(`/portfolio/${id}`),
  // Контактная форма
  sendContact: (data) => api.post('/contact', data),
  // Авторизация
  login: (credentials) => api.post('/auth/login', credentials),
  register: (userData) => api.post('/auth/register', userData)
}

export default api

Утилиты

Файл src/utils/imageUrl.js

export function getImageUrl(imageData) {
  const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost';
 
  const type = imageData.type?.trim() || 'standart';
  const variant = imageData.variant?.trim() || 'standart';
  const imageName = imageData.image?.trim();
 
  if (!imageName) {
    console.warn('Image name is missing', imageData);
    return `${baseUrl}/images/placeholder.jpg`;
  }
 
  const extension = 'jpg';
 
  return `${baseUrl}/images/${type}/${variant}/${imageName}.${extension}`;
}

Роутер

Файл src/router/index.js

import { createRouter, createWebHistory } from 'vue-router';
import Home from '../views/Home.vue';
import Portfolio from '@/views/Portfolio.vue';
import Contact from '../views/Contact.vue';
import PageDetail from '../views/PageDetail.vue';

const routes = [
  { path: '/', name: 'Home', component: Home },
  { path: '/portfolio', name: 'Portfolio', component: Portfolio },
  { path: '/contact', name: 'Contact', component: Contact },
  { path: '/page/:id', name: 'PageDetail', component: PageDetail },
  { path: '/:pathMatch(.*)*', redirect: '/' }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

export default router;

Компоненты

Файл src/components/NavBar.vue

<template>
  <nav class="navbar navbar-expand-lg navbar-dark">
    <div class="container">
      <router-link to="/" class="navbar-brand"></router-link>
     
      <button
        class="navbar-toggler"
        type="button"
        data-bs-toggle="collapse"
        data-bs-target="#navbarNav"
        aria-controls="navbarNav"
        aria-expanded="false"
        aria-label="Toggle navigation"
      >
        <span class="navbar-toggler-icon"></span>
      </button>
     
      <div class="collapse navbar-collapse" id="navbarNav">
        <ul class="navbar-nav ms-auto">
          <li class="nav-item">
            <router-link to="/" class="nav-link" active-class="active">
              Главная
            </router-link>
          </li>
          <li class="nav-item">
            <router-link to="/page/about" class="nav-link" active-class="active">
              О компании
            </router-link>
          </li>
         
          <li class="nav-item">
            <router-link to="/portfolio" class="nav-link" active-class="active">
              Портфолио
            </router-link>
          </li>
          <li class="nav-item">
            <router-link to="/contact" class="nav-link" active-class="active">
              Контакты
            </router-link>
          </li>
        </ul>
      </div>
    </div>
  </nav>
</template>

<script>
export default {
  name: 'NavBar'
}
</script>

<style scoped>
.navbar {
  background: #130602;
  padding-top: 1rem;
  padding-bottom: 1rem;
  position: sticky;
  position: absolute;
  z-index: 10;
  width: 100%;
  top: 0;
}
.navbar-brand {
  background: url(data:image/svg+xml;utf8;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB3aWR0aD0iMjc2LjRweCIKCSBoZWlnaHQ9IjU4LjlweCIgdmlld0JveD0iMCAwIDI3Ni40IDU4LjkiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDI3Ni40IDU4Ljk7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojRDNBMDczO30KCS5zdDF7ZmlsbDpub25lO3N0cm9rZTojRDNBMDczO3N0cm9rZS13aWR0aDozO3N0cm9rZS1taXRlcmxpbWl0OjEwO30KCS5zdDJ7ZmlsbDpub25lO3N0cm9rZTojRDNBMDczO3N0cm9rZS13aWR0aDo1O3N0cm9rZS1taXRlcmxpbWl0OjEwO30KCS5zdDN7ZmlsbDpub25lO3N0cm9rZTojNDgyODFEO3N0cm9rZS13aWR0aDo1Ljk7c3Ryb2tlLW1pdGVybGltaXQ6MTA7fQo8L3N0eWxlPgo8ZGVmcz4KPC9kZWZzPgo8Zz4KCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0xMDAuNyw0LjJ2MjUuMkg5NVYxNC44bC02LjUsMTAuN2gtMC42bC02LjUtMTAuN3YxNC43aC01LjhWNC4yaDUuOGw2LjgsMTEuMkw5NSw0LjJIMTAwLjd6Ii8+Cgk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNMTEwLjgsMjMuOWgxMC4xdjUuNUgxMDVWNC4yaDE1Ljd2NS41aC05LjlWMTRoOXY1LjVoLTlWMjMuOXoiLz4KCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0xMzMuOCwxMy42YzIuNiwwLDQuNywwLjcsNi4zLDIuMnMyLjQsMy40LDIuNCw1LjdzLTAuOCw0LjMtMi40LDUuN3MtMy43LDIuMi02LjMsMi4yaC05LjdWNC4yaDE2LjJ2NS41CgkJaC0xMC40djMuOEgxMzMuOHogTTEzMy44LDI0YzEuOSwwLDIuOS0wLjgsMi45LTIuNXMtMS0yLjUtMi45LTIuNWgtNHY1SDEzMy44eiIvPgoJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1MC45LDIzLjlIMTYxdjUuNWgtMTUuOFY0LjJoMTUuN3Y1LjVoLTkuOVYxNGg5djUuNWgtOVYyMy45eiIvPgoJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE2Mi41LDI5LjVWMjRjMS40LDAuMiwyLjQsMCwzLjEtMC42YzAuNy0wLjYsMS0yLDEtNFY0LjJoMTcuNHYyNS4yaC01LjhWOS44aC01Ljl2OS40YzAsMi4xLTAuMywzLjktMC45LDUuNAoJCWMtMC42LDEuNS0xLjQsMi42LTIuNCwzLjNjLTEsMC43LTIsMS4xLTMuMSwxLjRDMTY0LjksMjkuNSwxNjMuNywyOS42LDE2Mi41LDI5LjV6Ii8+Cgk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNMTk3LjksMTIuMmMyLjUsMCw0LjYsMC44LDYuMywyLjVzMi42LDMuNywyLjYsNi4xYzAsMi40LTAuOSw0LjQtMi42LDYuMXMtMy44LDIuNS02LjMsMi41aC05LjVWNC4yaDUuOHY3LjkKCQlIMTk3Ljl6IE0xOTcuOSwyNGMwLjksMCwxLjYtMC4zLDIuMi0wLjljMC42LTAuNiwwLjktMS40LDAuOS0yLjNzLTAuMy0xLjctMC45LTIuM2MtMC42LTAuNi0xLjMtMC45LTIuMi0wLjloLTMuOFYyNEgxOTcuOXoiLz4KCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0yMjMuMyw0LjJoNS44djI1LjJoLTUuOHYtMTBoLTcuOXYxMGgtNS44VjQuMmg1Ljh2OS42aDcuOVY0LjJ6Ii8+Cgk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNMjQ5LjQsMjkuNWwtMS4zLTRoLTkuNGwtMS4zLDRoLTYuM2w4LjYtMjUuMmg3LjNsOC42LDI1LjJIMjQ5LjR6IE0yNDAuNSwyMC4xaDUuOWwtMy05LjNMMjQwLjUsMjAuMXoiLz4KCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0yNzYuNCw0LjJ2MjUuMmgtNS44di04LjNoLTEuOGwtNS40LDguM0gyNTdsNS45LTljLTEuNS0wLjYtMi44LTEuNy0zLjctM3MtMS40LTIuOS0xLjQtNC41CgkJYzAtMi40LDAuOC00LjQsMi41LTYuMXMzLjctMi41LDYuMS0yLjVIMjc2LjR6IE0yNjYuMywxNi4xaDQuM1Y5LjdoLTQuM2MtMC44LDAtMS41LDAuMy0yLDAuOWMtMC42LDAuNi0wLjgsMS40LTAuOCwyLjMKCQlzMC4zLDEuNywwLjgsMi4zQzI2NC45LDE1LjgsMjY1LjUsMTYuMSwyNjYuMywxNi4xeiIvPgo8L2c+CjxnPgoJPHBhdGggY2xhc3M9InN0MCIgZD0iTTEzOC45LDQyLjljMS41LDEuMSwyLjIsMi43LDIuMiw0LjdjMCwyLTAuNywzLjUtMi4yLDQuN2MtMS41LDEuMS0zLjQsMS43LTUuOSwxLjhWNTZoLTEuN3YtMS45CgkJYy0yLjQtMC4xLTQuNC0wLjctNS45LTEuOGMtMS41LTEuMS0yLjItMi43LTIuMi00LjdjMC0yLDAuNy0zLjUsMi4yLTQuN2MxLjUtMS4xLDMuNC0xLjcsNS45LTEuOHYtMS45aDEuN3YxLjkKCQlDMTM1LjUsNDEuMiwxMzcuNSw0MS44LDEzOC45LDQyLjl6IE0xMjYuNyw0NGMtMS4yLDAuOC0xLjcsMi0xLjcsMy42YzAsMS41LDAuNiwyLjcsMS43LDMuNmMxLjIsMC44LDIuNywxLjMsNC43LDEuNHYtOS45CgkJQzEyOS40LDQyLjcsMTI3LjksNDMuMiwxMjYuNyw0NHogTTEzMy4xLDUyLjVjMi0wLjEsMy41LTAuNiw0LjctMS40YzEuMi0wLjgsMS43LTIsMS43LTMuNmMwLTEuNS0wLjYtMi43LTEuNy0zLjYKCQljLTEuMi0wLjgtMi43LTEuMy00LjctMS40VjUyLjV6Ii8+Cgk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNMTU0LjUsNTZsLTEuNi00LjFoLThsLTEuNiw0LjFoLTEuOGw2LjUtMTYuOGgxLjdsNi41LDE2LjhIMTU0LjV6IE0xNDUuNiw1MC4zaDYuOGwtMy40LTlMMTQ1LjYsNTAuM3oiLz4KCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0xNjQuNyw0NS45YzEuNCwwLDIuNiwwLjUsMy42LDEuNWMxLDEsMS41LDIuMiwxLjUsMy42YzAsMS40LTAuNSwyLjYtMS41LDMuNmMtMSwxLTIuMiwxLjUtMy42LDEuNWgtNi4yVjM5LjIKCQloOS44djEuNmgtOC4ydjUuMUgxNjQuN3ogTTE2NC43LDU0LjRjMC45LDAsMS43LTAuMywyLjQtMWMwLjctMC43LDEtMS41LDEtMi40YzAtMC45LTAuMy0xLjgtMS0yLjRjLTAuNy0wLjctMS40LTEtMi40LTFoLTQuNnY2LjkKCQlIMTY0Ljd6Ii8+Cgk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNMTc4LjUsMzkuMmMxLjUsMCwyLjgsMC41LDMuOCwxLjVzMS41LDIuMywxLjUsMy44cy0wLjUsMi44LTEuNSwzLjhzLTIuMywxLjUtMy44LDEuNWgtNC4zVjU2aC0xLjdWMzkuMgoJCUgxNzguNXogTTE3OC41LDQ4LjJjMSwwLDEuOS0wLjMsMi42LTFjMC43LTAuNywxLTEuNiwxLTIuN2MwLTEuMS0wLjMtMi0xLTIuN2MtMC43LTAuNy0xLjUtMS0yLjYtMWgtNC4zdjcuNEgxNzguNXoiLz4KCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0xOTcuNCw1NlY0Mi4zTDE4OC4xLDU2aC0xLjRWMzkuMmgxLjd2MTMuN2w5LjQtMTMuN2gxLjRWNTZIMTk3LjR6Ii8+Cgk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNMjE0LjcsNTZoLTIuMmwtNy45LTguMVY1NkgyMDNWMzkuMmgxLjd2Ny43bDcuNi03LjdoMi4ybC04LjEsOC4yTDIxNC43LDU2eiIvPgoJPHBhdGggY2xhc3M9InN0MCIgZD0iTTIyOC4zLDU2bC0xLjYtNC4xaC04bC0xLjYsNC4xaC0xLjhsNi41LTE2LjhoMS43bDYuNSwxNi44SDIyOC4zeiBNMjE5LjQsNTAuM2g2LjhsLTMuNC05TDIxOS40LDUwLjN6Ii8+CjwvZz4KPGxpbmUgY2xhc3M9InN0MSIgeDE9IjEwNy45IiB5MT0iNDcuNSIgeDI9IjgwLjkiIHkyPSI0Ny41Ii8+CjxsaW5lIGNsYXNzPSJzdDEiIHgxPSIyNjguOSIgeTE9IjQ3LjUiIHgyPSIyNDEuOSIgeTI9IjQ3LjUiLz4KPHBvbHlnb24gY2xhc3M9InN0MiIgcG9pbnRzPSIyLjUsMyAyLjUsNTYgMjEuNCw1NiAyMS40LDQzLjEgMzYuNiw0My4xIDM2LjYsMjkuNSAyMS40LDI5LjUgMjEuNCwxNi4yIDM2LjYsMTYuMiAzNi42LDMgIi8+Cjxwb2x5Z29uIGNsYXNzPSJzdDMiIHBvaW50cz0iNTUuNSw1NiA1NS41LDMgMzYuNiwzIDM2LjYsMTUuOCAyMS40LDE1LjggMjEuNCwyOS41IDM2LjYsMjkuNSAzNi42LDQyLjcgMjEuNCw0Mi43IDIxLjQsNTYgIi8+Cjwvc3ZnPg==);
  background-size: contain;
  background-repeat: no-repeat;
  width: 15rem;
  height: 3rem;
}
.nav-link {
  font-size: 1.1rem;
  margin: 0 0.5rem;
}
.nav-link.active {
  font-weight: bold;
}
.nav-link, .nav-link.active, .nav-link.show {
  color: var(--primary-bg-color);
}
.nav-link:focus, .nav-link:hover {
  color: var(--secondary-dark-color);
}
.navbar-toggler-icon{
  filter: brightness(0) saturate(100%) invert(100%) sepia(79%) saturate(5852%) hue-rotate(303deg) brightness(100%) contrast(100%);
}
</style>

Примечание: scoped в Vue.js - это атрибут, который добавляется к тегу <style> для ограничения области действия CSS стилей только текущим компонентом.

Файл src/components/FooterBar.vue

<template>
  <div class="footer-wrapper">
    <div
      class="smart-footer"
      :class="{ 'smart-footer--expanded': isExpanded }"
      @mouseenter="expandFooter"
      @mouseleave="scheduleCollapse"
    >
      <div class="footer-main">
        <div class="footer-info">
          <p>© IT-компания JAS 2025. Сделано с помощью модульной системы управления JCMS. Данный сайт является демонстрационной версией.<br>Вся информация на сайте несёт исключительно демонстрационный характер.</p>
        </div>
        <div class="footer-actions">
          <a href="https://www.jas.ru"><div class="d-inline-block logo jas-logo m-1"></div></a>
          <a href="https://www.jcms.ru"><div class="d-inline-block logo jcms-logo m-1"></div></a>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isExpanded: false,
      collapseTimer: null
    }
  },
  methods: {
    expandFooter() {
      this.isExpanded = true
      this.clearTimer()
    },
    scheduleCollapse() {
      this.clearTimer()
      this.collapseTimer = setTimeout(() => {
        this.isExpanded = false
      }, 2000)
    },
    clearTimer() {
      if (this.collapseTimer) {
        clearTimeout(this.collapseTimer)
        this.collapseTimer = null
      }
    }
  },
  beforeUnmount() {
    this.clearTimer()
  }
}
</script>

<style scoped>
.smart-footer {
  position: fixed;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 100px;
  height: 30px;
  background: var(--primary-dark-color);
  background: linear-gradient(45deg, var(--primary-dark-color) 0%, var(--secondary-dark-color) 100%);
  transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
  cursor: pointer;
  overflow: hidden;
}
.smart-footer--expanded {
  width: 100%;
  height: 100px;
  background: var(--primary-dark-color);
  background: linear-gradient(135deg, var(--secondary-dark-color) 0%, var(--primary-dark-color) 100%);
  border-radius: 0;
}
.footer-main {
  opacity: 0;
  transform: translateY(20px);
  transition: all 0.3s ease 0.2s;
  padding: 15px 30px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  color: white;
  height: 100%;
}
.smart-footer--expanded .footer-main {
  opacity: 1;
  transform: translateY(0);
}
.footer-info h4 {
  margin: 0 0 5px 0;
  font-size: 1.1em;
}
.footer-info p {
  margin: 0;
  font-size: 0.7em;
  opacity: 0.8;
}
</style>

Файл src/components/Loader.vue

<template>
  <div class="d-flex justify-content-center vh-100 align-items-center">
    <div class="loader text-primary" role="status">
      <span class="visually-hidden">Загрузка...</span>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Loader'
}
</script>

Файл src/components/PageViewer.vue

<template>
  <div v-if="loading">
    <div class="container">
      <div class="text-center"><Loader /></div>
    </div>
  </div>
  <div v-else-if="page">
    <div class="title d-flex justify-content-center align-items-center ">
      <h1 class="text-center">{{ page.title }}
        <p v-html="page.description"></p>
      </h1>
    </div>
    <div class="container mt-10 mb-10">
      <div class="page-content">
      <div class="row justify-content-center">
        <div class="col-lg-12">
          <div v-html="page.text" class="page-body row"></div>
        </div>
      </div>
    </div>
    </div>
  </div>
  <div v-else>
    <div class="container mt-10">
      <div class="d-flex justify-content-center vh-100 text-center align-items-center">
        <h1 class="not-found">Страница не найдена<p>Запрошенная страница не существует или временно недоступна</p></h1>
      </div>
    </div>
  </div>
</template>

<script>
import { apiService } from '../services/api'
import Loader from './Loader.vue'
export default {
  name: 'PageViewer',
  components: {
    Loader
  },
  props: {
    pageId: {
      type: [String, Number],
      required: true
    }
  },
  data() {
    return {
      page: null,
      loading: false,
      error: null
    }
  },
  watch: {
    pageId: {
      immediate: true,
      handler() {
        this.loadPage()
      }
    }
  },
  methods: {
    async loadPage() {
      this.loading = true
      this.error = null
     
      try {
        const response = await apiService.getPage(this.pageId)
        this.page = response.data || response
      } catch (error) {
        console.error('Ошибка загрузки страницы:', error)
        this.error = 'Не удалось загрузить страницу'
      } finally {
        this.loading = false
      }
    }
  }
}
</script>

<style scoped>
.page-body {
  line-height: 1.8rem;
  font-size: 1.1rem;
}
.page-body :deep(h2), .page-body :deep(h3) {
  margin: 1.5rem 0 1rem 0;
}
.page-body :deep(img) {
  max-width: 100%;
  height: auto;
  margin: 1rem 0;
  filter: brightness(0.9);
  border: 1px solid #d2d2d2;
}
.mt-10 {
  margin-top: 2rem !important;
}
.mb-10 {
  margin-bottom: 8rem !important;
}
.page-body :deep(ul) {
    list-style: none;
    padding: 0;
    margin: 0;
}
.page-body :deep(ul) li {
    padding: 12px 16px;
    margin: 8px 0;
    background: var(--secondary-lite-color);
    border-left: 4px solid var(--primary-dark-color);
    border-radius: 0px;
    transition: all 0.3s ease;
}
.page-body :deep(ul) li:hover {
    background: var(--primary-bg-color);
    transform: translateX(5px);
}
</style>

Файл src/components/Slider.vue

<template>
    <div class="fullscreen-slider">
        <Loader v-if="loading" />
        <div v-for="(slide, index) in slides"
             :key="index"
             :class="['slide', { active: currentIndex === index }]">
            <div class="slide-background" :style="{ backgroundImage: 'url(' + slide.image + ')' }"></div>
            <div class="slide-content">
                <h2>{{ slide.title }}</h2>
                <p>{{ slide.description }}</p>
            </div>
        </div>
        <button class="carousel-control-prev" type="button" @click="prevSlide">
            <span class="carousel-control-prev-icon" aria-hidden="true"></span>
            <span class="visually-hidden">Предыдущий</span>
        </button>
        <button class="carousel-control-next" type="button" @click="nextSlide">
            <span class="carousel-control-next-icon" aria-hidden="true"></span>
            <span class="visually-hidden">Следующий</span>
        </button>
        <div class="carousel-indicators">
            <button v-for="(slide, index) in slides"
                    :key="index"
                    :class="{ active: currentIndex === index }"
                    @click="setSlide(index)"></button>
        </div>
    </div>
</template>

<script>
import Loader from './Loader.vue'
export default {
  name: 'Slider',
  components: {
    Loader
  },
  data() {
    return {
      currentIndex: 0,
      loading: true,
      error: null,
      loadedImages: 0,
      totalImages: 0,
      slides: [
            {
                image: 'https://demo-furniture-factory.jcms.ru/images/bg4.jpg',
                title: 'Собственное производство',
                description: 'Полный контроль над каждым этапом — от распила до финальной сборки. Это наше качество и ваша уверенность.'
            },
            {
                image: 'https://demo-furniture-factory.jcms.ru/images/bg3.jpg',
                title: 'Индивидуальный подход',
                description: 'Нет стандартного решения? Это к нам! Мы создадим мебель точно по вашим размера и эскизам.'
            },
            {
                image: 'https://demo-furniture-factory.jcms.ru/images/bg10.jpg',
                title: 'Экологичность и безопасность',
                description: 'Мы используем материалы с сертификатами качества: ЛДСП класса E0.5, массив дерева, безопасные лаки и краски.'
            },
            {
                image: 'https://demo-furniture-factory.jcms.ru/images/bg12.jpg',
                title: 'Прозрачность работы',
                description: 'Вы знаете, из чего сложена конечная цена. Никаких скрытых доплат на последнем этапе.'
            },
            {
                image: 'https://demo-furniture-factory.jcms.ru/images/bg8.jpg',
                title: 'Комплекс услуг',
                description: 'Бесплатный замер, помощь в разработке дизайн-проекта, доставка и профессиональная сборка. Всё в одних руках.'
            }
        ]
    }
  },
  mounted() {
    this.totalImages = this.slides.length;
    if (this.totalImages === 0) {
      this.loading = false;
    } else {
      this.startAutoPlay();
    }
  },
  beforeUnmount() {
    this.stopAutoPlay();
  },
  methods: {
    nextSlide() {
        this.currentIndex = (this.currentIndex + 1) % this.slides.length;
    },
    prevSlide() {
        this.currentIndex = (this.currentIndex - 1 + this.slides.length) % this.slides.length;
    },
    setSlide(index) {
        this.currentIndex = index;
    },
    startAutoPlay() {
        this.autoPlayInterval = setInterval(() => {
            this.nextSlide();
        }, 10000);
    },
    stopAutoPlay() {
        clearInterval(this.autoPlayInterval);
    },
    imageLoaded() {
      this.loadedImages++;
      if (this.loadedImages >= this.totalImages) {
        this.loading = false;
      }
    },
    imageError() {
      this.loadedImages++;
      if (this.loadedImages >= this.totalImages) {
        this.loading = false;
      }
    }
  }
}
</script>

Продолжение статьи Создание простого приложение на Vue.js (часть 2).

Поделиться: