From 2d90e903cbf31bfcb3bec8d412d71dda982bb68f Mon Sep 17 00:00:00 2001 From: Keks Date: Thu, 19 Sep 2024 14:21:00 +0300 Subject: [PATCH] :cat: --- src/framework/api-service.js | 70 +++++++++++++++++ src/framework/observable.js | 39 ++++++++++ src/framework/render.js | 80 ++++++++++++++++++++ src/framework/ui-blocker/ui-blocker.css | 49 ++++++++++++ src/framework/ui-blocker/ui-blocker.js | 80 ++++++++++++++++++++ src/framework/view/abstract-stateful-view.js | 52 +++++++++++++ src/framework/view/abstract-view.css | 27 +++++++ src/framework/view/abstract-view.js | 65 ++++++++++++++++ 8 files changed, 462 insertions(+) create mode 100644 src/framework/api-service.js create mode 100644 src/framework/observable.js create mode 100644 src/framework/render.js create mode 100644 src/framework/ui-blocker/ui-blocker.css create mode 100644 src/framework/ui-blocker/ui-blocker.js create mode 100644 src/framework/view/abstract-stateful-view.js create mode 100644 src/framework/view/abstract-view.css create mode 100644 src/framework/view/abstract-view.js diff --git a/src/framework/api-service.js b/src/framework/api-service.js new file mode 100644 index 0000000..ef9f928 --- /dev/null +++ b/src/framework/api-service.js @@ -0,0 +1,70 @@ +/** + * Класс для отправки запросов к серверу + */ +export default class ApiService { + /** + * @param {string} endPoint Адрес сервера + * @param {string} authorization Авторизационный токен + */ + constructor(endPoint, authorization) { + this._endPoint = endPoint; + this._authorization = authorization; + } + + /** + * Метод для отправки запроса к серверу + * @param {Object} config Объект с настройками + * @param {string} config.url Адрес относительно сервера + * @param {string} [config.method] Метод запроса + * @param {string} [config.body] Тело запроса + * @param {Headers} [config.headers] Заголовки запроса + * @returns {Promise} + */ + async _load({ + url, + method = 'GET', + body = null, + headers = new Headers(), + }) { + headers.append('Authorization', this._authorization); + + const response = await fetch( + `${this._endPoint}/${url}`, + {method, body, headers}, + ); + + try { + ApiService.checkStatus(response); + return response; + } catch (err) { + ApiService.catchError(err); + } + } + + /** + * Метод для обработки ответа + * @param {Response} response Объект ответа + * @returns {Promise} + */ + static parseResponse(response) { + return response.json(); + } + + /** + * Метод для проверки ответа + * @param {Response} response Объект ответа + */ + static checkStatus(response) { + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + } + + /** + * Метод для обработки ошибок + * @param {Error} err Объект ошибки + */ + static catchError(err) { + throw err; + } +} diff --git a/src/framework/observable.js b/src/framework/observable.js new file mode 100644 index 0000000..026045d --- /dev/null +++ b/src/framework/observable.js @@ -0,0 +1,39 @@ +/** + * Класс, реализующий паттерн Наблюдатель. + */ +export default class Observable { + /** @type {Set} Множество функций типа observerCallback */ + #observers = new Set(); + + /** + * Метод, позволяющий подписаться на событие + * @param {observerCallback} observer Функция, которая будет вызвана при наступлении события + */ + addObserver(observer) { + this.#observers.add(observer); + } + + /** + * Метод, позволяющий отписаться от события + * @param {observerCallback} observer Функция, которую больше не нужно вызывать при наступлении события + */ + removeObserver(observer) { + this.#observers.delete(observer); + } + + /** + * Метод для оповещения подписчиков о наступлении события + * @param {*} event Тип события + * @param {*} payload Дополнительная информация + */ + _notify(event, payload) { + this.#observers.forEach((observer) => observer(event, payload)); + } +} + +/** + * Функция, которая будет вызвана при наступлении события + * @callback observerCallback + * @param {*} event Тип события + * @param {*} [payload] Дополнительная информация + */ diff --git a/src/framework/render.js b/src/framework/render.js new file mode 100644 index 0000000..2e089db --- /dev/null +++ b/src/framework/render.js @@ -0,0 +1,80 @@ +import AbstractView from './view/abstract-view.js'; + +/** @enum {string} Перечисление возможных позиций для отрисовки */ +const RenderPosition = { + BEFOREBEGIN: 'beforebegin', + AFTERBEGIN: 'afterbegin', + BEFOREEND: 'beforeend', + AFTEREND: 'afterend', +}; + +/** + * Функция для создания элемента на основе разметки + * @param {string} template Разметка в виде строки + * @returns {HTMLElement} Созданный элемент + */ +function createElement(template) { + const newElement = document.createElement('div'); + newElement.innerHTML = template; + + return newElement.firstElementChild; +} + +/** + * Функция для отрисовки элемента + * @param {AbstractView} component Компонент, который должен был отрисован + * @param {HTMLElement} container Элемент в котором будет отрисован компонент + * @param {string} place Позиция компонента относительно контейнера. По умолчанию - `beforeend` + */ +function render(component, container, place = RenderPosition.BEFOREEND) { + if (!(component instanceof AbstractView)) { + throw new Error('Can render only components'); + } + + if (container === null) { + throw new Error('Container element doesn\'t exist'); + } + + container.insertAdjacentElement(place, component.element); +} + +/** + * Функция для замены одного компонента на другой + * @param {AbstractView} newComponent Компонент, который нужно показать + * @param {AbstractView} oldComponent Компонент, который нужно скрыть + */ +function replace(newComponent, oldComponent) { + if (!(newComponent instanceof AbstractView && oldComponent instanceof AbstractView)) { + throw new Error('Can replace only components'); + } + + const newElement = newComponent.element; + const oldElement = oldComponent.element; + + const parent = oldElement.parentElement; + + if (parent === null) { + throw new Error('Parent element doesn\'t exist'); + } + + parent.replaceChild(newElement, oldElement); +} + +/** + * Функция для удаления компонента + * @param {AbstractView} component Компонент, который нужно удалить + */ +function remove(component) { + if (component === null) { + return; + } + + if (!(component instanceof AbstractView)) { + throw new Error('Can remove only components'); + } + + component.element.remove(); + component.removeElement(); +} + +export {RenderPosition, createElement, render, replace, remove}; diff --git a/src/framework/ui-blocker/ui-blocker.css b/src/framework/ui-blocker/ui-blocker.css new file mode 100644 index 0000000..489756d --- /dev/null +++ b/src/framework/ui-blocker/ui-blocker.css @@ -0,0 +1,49 @@ +.ui-blocker { + display: none; + place-content: center; + position: fixed; + top: 0; + left: 0; + min-width: 100%; + min-height: 100%; + z-index: 1000; + cursor: wait; + background-color: rgba(255, 255, 255, 0.5); +} + +.ui-blocker::before { + content: ""; + display: block; + border-radius: 50%; + border: 6px solid #4285F4; + box-sizing: border-box; + animation: sweep 1s linear alternate infinite, + rotate 0.8s linear infinite; + width: 65px; + height: 65px; +} + +.ui-blocker--on { + display: grid; +} + +@keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes sweep { + 0% { + clip-path: polygon(0% 0%, 0% 0%, 0% 0%, 50% 50%, 0% 0%, 0% 0%, 0% 0%); + } + 50% { + clip-path: polygon(0% 0%, 0% 100%, 0% 100%, 50% 50%, 100% 0%, 100% 0%, 0% 0%); + } + 100% { + clip-path: polygon(0% 0%, 0% 100%, 100% 100%, 50% 50%, 100% 100%, 100% 0%, 0% 0%); + } +} diff --git a/src/framework/ui-blocker/ui-blocker.js b/src/framework/ui-blocker/ui-blocker.js new file mode 100644 index 0000000..879e8f9 --- /dev/null +++ b/src/framework/ui-blocker/ui-blocker.js @@ -0,0 +1,80 @@ +import './ui-blocker.css'; + +/** + * Класс для блокировки интерфейса + */ +export default class UiBlocker { + /** @type {number} Время до блокировки интерфейса в миллисекундах */ + #lowerLimit; + + /** @type {number} Минимальное время блокировки интерфейса в миллисекундах */ + #upperLimit; + + /** @type {HTMLElement|null} Элемент, блокирующий интерфейс */ + #element; + + /** @type {number} Время вызова метода block */ + #startTime; + + /** @type {number} Время вызова метода unblock */ + #endTime; + + /** @type {number} Идентификатор таймера */ + #timerId; + + /** + * @param {Object} config Объект с настройками блокировщика + * @param {number} config.lowerLimit Время до блокировки интерфейса в миллисекундах. Если вызвать метод unblock раньше, то интерфейс заблокирован не будет + * @param {number} config.upperLimit Минимальное время блокировки в миллисекундах. Минимальная длительность блокировки + */ + constructor({lowerLimit, upperLimit}) { + this.#lowerLimit = lowerLimit; + this.#upperLimit = upperLimit; + + this.#element = document.createElement('div'); + this.#element.classList.add('ui-blocker'); + document.body.append(this.#element); + } + + /** Метод для блокировки интерфейса */ + block() { + this.#startTime = Date.now(); + this.#timerId = setTimeout(() => { + this.#activateBlocking(); + }, this.#lowerLimit); + } + + /** Метод для разблокировки интерфейса */ + unblock() { + this.#endTime = Date.now(); + const duration = this.#endTime - this.#startTime; + + if (duration < this.#lowerLimit) { + clearTimeout(this.#timerId); + return; + } + + if (duration >= this.#upperLimit) { + this.#disactivateBlocking(); + return; + } + + setTimeout(this.#disactivateBlocking, this.#upperLimit - duration); + } + + /** Метод, добавляющий CSS-класс и обработчик */ + #activateBlocking = () => { + this.#element.classList.add('ui-blocker--on'); + document.addEventListener('keydown', this.#documentKeydownHandler); + }; + + /** Метод, убирающий CSS-класс и обработчик */ + #disactivateBlocking = () => { + this.#element.classList.remove('ui-blocker--on'); + document.removeEventListener('keydown', this.#documentKeydownHandler); + }; + + #documentKeydownHandler = (evt) => { + evt.preventDefault(); + }; +} diff --git a/src/framework/view/abstract-stateful-view.js b/src/framework/view/abstract-stateful-view.js new file mode 100644 index 0000000..66f68ae --- /dev/null +++ b/src/framework/view/abstract-stateful-view.js @@ -0,0 +1,52 @@ +import AbstractView from './abstract-view.js'; + +/** + * Абстрактный класс представления с состоянием + */ +export default class AbstractStatefulView extends AbstractView { + /** @type {Object} Объект состояния */ + _state = {}; + + /** + * Метод для обновления состояния и перерисовки элемента + * @param {Object} update Объект с обновлённой частью состояния + */ + updateElement(update) { + if (!update) { + return; + } + + this._setState(update); + + this.#rerenderElement(); + } + + /** + * Метод для восстановления обработчиков после перерисовки элемента + * @abstract + */ + _restoreHandlers() { + throw new Error('Abstract method not implemented: restoreHandlers'); + } + + /** + * Метод для обновления состояния + * @param {Object} update Объект с обновлённой частью состояния + */ + _setState(update) { + this._state = structuredClone({...this._state, ...update}); + } + + /** Метод для перерисовки элемента */ + #rerenderElement() { + const prevElement = this.element; + const parent = prevElement.parentElement; + this.removeElement(); + + const newElement = this.element; + + parent.replaceChild(newElement, prevElement); + + this._restoreHandlers(); + } +} diff --git a/src/framework/view/abstract-view.css b/src/framework/view/abstract-view.css new file mode 100644 index 0000000..04070e2 --- /dev/null +++ b/src/framework/view/abstract-view.css @@ -0,0 +1,27 @@ +.shake { + animation: shake 0.6s; + position: relative; + z-index: 10; +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + + 10%, + 30%, + 50%, + 70%, + 90% { + transform: translateX(-5px); + } + + 20%, + 40%, + 60%, + 80% { + transform: translateX(5px); + } +} diff --git a/src/framework/view/abstract-view.js b/src/framework/view/abstract-view.js new file mode 100644 index 0000000..fa2a552 --- /dev/null +++ b/src/framework/view/abstract-view.js @@ -0,0 +1,65 @@ +import {createElement} from '../render.js'; +import './abstract-view.css'; + +/** @const {string} Класс, реализующий эффект "покачивания головой" */ +const SHAKE_CLASS_NAME = 'shake'; + +/** @const {number} Время анимации в миллисекундах */ +const SHAKE_ANIMATION_TIMEOUT = 600; + +/** + * Абстрактный класс представления + */ +export default class AbstractView { + /** @type {HTMLElement|null} Элемент представления */ + #element = null; + + constructor() { + if (new.target === AbstractView) { + throw new Error('Can\'t instantiate AbstractView, only concrete one.'); + } + } + + /** + * Геттер для получения элемента + * @returns {HTMLElement} Элемент представления + */ + get element() { + if (!this.#element) { + this.#element = createElement(this.template); + } + + return this.#element; + } + + /** + * Геттер для получения разметки элемента + * @abstract + * @returns {string} Разметка элемента в виде строки + */ + get template() { + throw new Error('Abstract method not implemented: get template'); + } + + /** Метод для удаления элемента */ + removeElement() { + this.#element = null; + } + + /** + * Метод, реализующий эффект "покачивания головой" + * @param {shakeCallback} [callback] Функция, которая будет вызвана после завершения анимации + */ + shake(callback) { + this.element.classList.add(SHAKE_CLASS_NAME); + setTimeout(() => { + this.element.classList.remove(SHAKE_CLASS_NAME); + callback?.(); + }, SHAKE_ANIMATION_TIMEOUT); + } +} + +/** + * Функция, которая будет вызвана методом shake после завершения анимации + * @callback shakeCallback + */