diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c85804c96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules/ +/dist/ \ No newline at end of file diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 000000000..0fe5deb5b --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,2 @@ +tabWidth: 4 +singleQuote: true diff --git a/README.md b/README.md index 34719e139..1fffa0b83 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,16 @@ MVP социальной сети для обмена фотографиями +1. Добавить функионал удаления собственных постов. + + Удалять можно как в общей ленте постов, так и при просмотре конкретного пользователя. + + Удалять посты другого пользователя нельзя, кнопка удалить не активна. + ## Первоначальная оценка -ХХХХ часов +72 часа ## Фактически затраченное время -YYYY часов +40 часов diff --git a/api.js b/api.js index 123a9e3b6..abd7d3855 100644 --- a/api.js +++ b/api.js @@ -1,69 +1,138 @@ // Замени на свой, чтобы получить независимый от других набор данных. // "боевая" версия инстапро лежит в ключе prod -const personalKey = "prod"; -const baseHost = "https://webdev-hw-api.vercel.app"; +const personalKey = 'avrusskov'; +const baseHost = 'https://webdev-hw-api.vercel.app'; const postsHost = `${baseHost}/api/v1/${personalKey}/instapro`; export function getPosts({ token }) { - return fetch(postsHost, { - method: "GET", - headers: { - Authorization: token, - }, - }) - .then((response) => { - if (response.status === 401) { - throw new Error("Нет авторизации"); - } + return fetch(postsHost, { + method: 'GET', + headers: { + Authorization: token, + }, + }) + .then((response) => { + if (response.status === 401) { + throw new Error('Нет авторизации'); + } + + return response.json(); + }) + .then((data) => { + return data.posts; + }); +} - return response.json(); +export function getUserPosts({ token }, userId) { + return fetch(`${postsHost}/user-posts/${userId}`, { + method: 'GET', + headers: { + Authorization: token, + }, }) - .then((data) => { - return data.posts; + .then((response) => { + if (response.status === 401) { + throw new Error('Нет авторизации'); + } + + return response.json(); + }) + .then((data) => { + return data.posts; + }); +} + +export function addPost({ token }, description, imageUrl) { + return fetch(postsHost, { + method: 'POST', + headers: { + Authorization: token, + }, + body: JSON.stringify({ description, imageUrl }), + }).then((response) => { + if (response.status === 401) { + throw new Error('Нет авторизации'); + } + + return; + }); +} + +export function deletePost({ token }, postId) { + return fetch(`${postsHost}/${postId}`, { + method: 'DELETE', + headers: { Authorization: token }, + }).then((response) => { + if (response.status === 401) { + throw new Error('Нет авторизации'); + } + + return; }); } export function registerUser({ login, password, name, imageUrl }) { - return fetch(baseHost + "/api/user", { - method: "POST", - body: JSON.stringify({ - login, - password, - name, - imageUrl, - }), - }).then((response) => { - if (response.status === 400) { - throw new Error("Такой пользователь уже существует"); - } - return response.json(); - }); + return fetch(baseHost + '/api/user', { + method: 'POST', + body: JSON.stringify({ + login, + password, + name, + imageUrl, + }), + }).then((response) => { + if (response.status === 400) { + throw new Error('Такой пользователь уже существует'); + } + return response.json(); + }); } export function loginUser({ login, password }) { - return fetch(baseHost + "/api/user/login", { - method: "POST", - body: JSON.stringify({ - login, - password, - }), - }).then((response) => { - if (response.status === 400) { - throw new Error("Неверный логин или пароль"); - } - return response.json(); - }); + return fetch(baseHost + '/api/user/login', { + method: 'POST', + body: JSON.stringify({ + login, + password, + }), + }).then((response) => { + if (response.status === 400) { + throw new Error('Неверный логин или пароль'); + } + return response.json(); + }); } // Загружает картинку в облако, возвращает url загруженной картинки export function uploadImage({ file }) { - const data = new FormData(); - data.append("file", file); + const data = new FormData(); + data.append('file', file); + + return fetch(baseHost + '/api/upload/image', { + method: 'POST', + body: data, + }).then((response) => { + return response.json(); + }); +} + +export function likePost({ token }, postId, isLiked) { + const flag = isLiked ? 'dislike' : 'like'; + + return fetch(`${postsHost}/${postId}/${flag}`, { + method: 'POST', + headers: { + Authorization: token, + }, + }) + .then((response) => { + if (response.status === 401) { + throw new Error('Нет авторизации'); + } - return fetch(baseHost + "/api/upload/image", { - method: "POST", - body: data, - }).then((response) => { - return response.json(); - }); + return response.json(); + }) + .then((data) => { + return data.post; + }); } diff --git a/assets/images/3dots.png b/assets/images/3dots.png new file mode 100644 index 000000000..bdfc310fa Binary files /dev/null and b/assets/images/3dots.png differ diff --git a/components/add-post-page-component.js b/components/add-post-page-component.js index 8277f093b..838d2de91 100644 --- a/components/add-post-page-component.js +++ b/components/add-post-page-component.js @@ -1,23 +1,71 @@ +import { replaceHtmlTags } from '../helpers.js'; +import { renderHeaderComponent } from './header-component.js'; +import { renderUploadImageComponent } from './upload-image-component.js'; + export function renderAddPostPageComponent({ appEl, onAddPostClick }) { - const render = () => { - // @TODO: Реализовать страницу добавления поста - const appHtml = ` -
-
- Cтраница добавления поста - -
- `; - - appEl.innerHTML = appHtml; - - document.getElementById("add-button").addEventListener("click", () => { - onAddPostClick({ - description: "Описание картинки", - imageUrl: "https://image.png", - }); - }); - }; - - render(); + let imageUrl = ''; + + const render = () => { + const appHtml = ` +
+
+
+

+ Добавление поста +

+
+
+ + + +
+
+ +
+ `; + + appEl.innerHTML = appHtml; + + renderHeaderComponent({ + element: document.querySelector('.header-container'), + }); + + const uploadImageContainer = appEl.querySelector( + '.upload-image-container', + ); + if (uploadImageContainer) { + renderUploadImageComponent({ + element: uploadImageContainer, + onImageUrlChange(newImageUrl) { + imageUrl = newImageUrl; + }, + }); + } + + document.getElementById('add-button').addEventListener('click', () => { + const description = document.getElementById('description-input'); + + if (!description.value) { + alert('Введите комментарий'); + return; + } + + if (!imageUrl) { + alert('Выберите фото'); + return; + } + onAddPostClick({ + description: description.value, + imageUrl: imageUrl, + }); + }); + }; + + render(); } diff --git a/components/auth-page-component.js b/components/auth-page-component.js index 0e93530ec..4405a450d 100644 --- a/components/auth-page-component.js +++ b/components/auth-page-component.js @@ -1,6 +1,7 @@ -import { loginUser, registerUser } from "../api.js"; -import { renderHeaderComponent } from "./header-component.js"; -import { renderUploadImageComponent } from "./upload-image-component.js"; +import { loginUser, registerUser } from '../api.js'; +import { renderHeaderComponent } from './header-component.js'; +import { renderUploadImageComponent } from './upload-image-component.js'; +import { replaceHtmlTags } from '../helpers.js'; /** * Компонент страницы авторизации. @@ -12,57 +13,57 @@ import { renderUploadImageComponent } from "./upload-image-component.js"; * Принимает объект пользователя в качестве аргумента. */ export function renderAuthPageComponent({ appEl, setUser }) { - /** - * Флаг, указывающий текущий режим формы. - * Если `true`, форма находится в режиме входа. Если `false`, в режиме регистрации. - * @type {boolean} - */ - let isLoginMode = true; - - /** - * URL изображения, загруженного пользователем при регистрации. - * Используется только в режиме регистрации. - * @type {string} - */ - let imageUrl = ""; - - /** - * Рендерит форму авторизации или регистрации. - * В зависимости от значения `isLoginMode` отображает соответствующий интерфейс. - */ - const renderForm = () => { - const appHtml = ` + /** + * Флаг, указывающий текущий режим формы. + * Если `true`, форма находится в режиме входа. Если `false`, в режиме регистрации. + * @type {boolean} + */ + let isLoginMode = true; + + /** + * URL изображения, загруженного пользователем при регистрации. + * Используется только в режиме регистрации. + * @type {string} + */ + let imageUrl = ''; + + /** + * Рендерит форму авторизации или регистрации. + * В зависимости от значения `isLoginMode` отображает соответствующий интерфейс. + */ + const renderForm = () => { + const appHtml = `

${ - isLoginMode - ? "Вход в Instapro" - : "Регистрация в Instapro" + isLoginMode + ? 'Вход в Instapro' + : 'Регистрация в Instapro' }

${ - !isLoginMode - ? ` + !isLoginMode + ? `
` - : "" + : '' }
@@ -70,103 +71,111 @@ export function renderAuthPageComponent({ appEl, setUser }) {
`; - appEl.innerHTML = appHtml; - - /** - * Устанавливает сообщение об ошибке в форме. - * @param {string} message - Текст сообщения об ошибке. - */ - const setError = (message) => { - appEl.querySelector(".form-error").textContent = message; - }; - - // Рендерим заголовок страницы - renderHeaderComponent({ - element: document.querySelector(".header-container"), - }); - - // Если режим регистрации, рендерим компонент загрузки изображения - const uploadImageContainer = appEl.querySelector(".upload-image-container"); - if (uploadImageContainer) { - renderUploadImageComponent({ - element: uploadImageContainer, - onImageUrlChange(newImageUrl) { - imageUrl = newImageUrl; - }, - }); - } - - // Обработка клика на кнопку входа/регистрации - document.getElementById("login-button").addEventListener("click", () => { - setError(""); - - if (isLoginMode) { - // Обработка входа - const login = document.getElementById("login-input").value; - const password = document.getElementById("password-input").value; - - if (!login) { - alert("Введите логин"); - return; - } - - if (!password) { - alert("Введите пароль"); - return; - } - - loginUser({ login, password }) - .then((user) => { - setUser(user.user); - }) - .catch((error) => { - console.warn(error); - setError(error.message); - }); - } else { - // Обработка регистрации - const login = document.getElementById("login-input").value; - const name = document.getElementById("name-input").value; - const password = document.getElementById("password-input").value; - - if (!name) { - alert("Введите имя"); - return; - } - - if (!login) { - alert("Введите логин"); - return; - } - - if (!password) { - alert("Введите пароль"); - return; + appEl.innerHTML = appHtml; + + /** + * Устанавливает сообщение об ошибке в форме. + * @param {string} message - Текст сообщения об ошибке. + */ + const setError = (message) => { + appEl.querySelector('.form-error').textContent = message; + }; + + // Рендерим заголовок страницы + renderHeaderComponent({ + element: document.querySelector('.header-container'), + }); + + // Если режим регистрации, рендерим компонент загрузки изображения + const uploadImageContainer = appEl.querySelector( + '.upload-image-container', + ); + if (uploadImageContainer) { + renderUploadImageComponent({ + element: uploadImageContainer, + onImageUrlChange(newImageUrl) { + imageUrl = newImageUrl; + }, + }); } - if (!imageUrl) { - alert("Не выбрана фотография"); - return; - } + // Обработка клика на кнопку входа/регистрации + document + .getElementById('login-button') + .addEventListener('click', () => { + setError(''); + + if (isLoginMode) { + // Обработка входа + const login = document.getElementById('login-input').value; + const password = + document.getElementById('password-input').value; + + if (!login) { + alert('Введите логин'); + return; + } + + if (!password) { + alert('Введите пароль'); + return; + } + console.log(replaceHtmlTags(login)); + loginUser({ login, password }) + .then((user) => { + setUser(user.user); + }) + .catch((error) => { + console.warn(error); + setError(error.message); + }); + } else { + // Обработка регистрации + const login = document.getElementById('login-input').value; + const name = document.getElementById('name-input').value; + const password = + document.getElementById('password-input').value; + + if (!name) { + alert('Введите имя'); + return; + } + + if (!login) { + alert('Введите логин'); + return; + } + + if (!password) { + alert('Введите пароль'); + return; + } + + if (!imageUrl) { + alert('Не выбрана фотография'); + return; + } + + registerUser({ login, password, name, imageUrl }) + .then((user) => { + setUser(user.user); + }) + .catch((error) => { + console.warn(error); + setError(error.message); + }); + } + }); + + // Обработка переключения режима (вход ↔ регистрация) + document + .getElementById('toggle-button') + .addEventListener('click', () => { + isLoginMode = !isLoginMode; + renderForm(); // Перерисовываем форму с новым режимом + }); + }; - registerUser({ login, password, name, imageUrl }) - .then((user) => { - setUser(user.user); - }) - .catch((error) => { - console.warn(error); - setError(error.message); - }); - } - }); - - // Обработка переключения режима (вход ↔ регистрация) - document.getElementById("toggle-button").addEventListener("click", () => { - isLoginMode = !isLoginMode; - renderForm(); // Перерисовываем форму с новым режимом - }); - }; - - // Инициализация формы - renderForm(); + // Инициализация формы + renderForm(); } diff --git a/components/header-component.js b/components/header-component.js index e733a39dc..f1be6e440 100644 --- a/components/header-component.js +++ b/components/header-component.js @@ -1,63 +1,67 @@ -import { goToPage, logout, user } from "../index.js"; -import { ADD_POSTS_PAGE, AUTH_PAGE, POSTS_PAGE } from "../routes.js"; +import { replaceHtmlTags } from '../helpers.js'; +import { goToPage, logout, user } from '../index.js'; +import { ADD_POSTS_PAGE, AUTH_PAGE, POSTS_PAGE } from '../routes.js'; /** * Компонент заголовка страницы. * Этот компонент отображает шапку страницы с логотипом, кнопкой добавления постов/входа и кнопкой выхода (если пользователь авторизован). - * + * * @param {HTMLElement} params.element - HTML-элемент, в который будет рендериться заголовок. * @returns {HTMLElement} Возвращает элемент заголовка после рендеринга. */ export function renderHeaderComponent({ element }) { - /** - * Рендерит содержимое заголовка. - */ - element.innerHTML = ` + /** + * Рендерит содержимое заголовка. + */ + element.innerHTML = ` `; - /** - * Обработчик клика по кнопке "Добавить пост"/"Войти". - * Если пользователь авторизован, перенаправляет на страницу добавления постов. - * Если пользователь не авторизован, перенаправляет на страницу авторизации. - */ - element - .querySelector(".add-or-login-button") - .addEventListener("click", () => { - if (user) { - goToPage(ADD_POSTS_PAGE); - } else { - goToPage(AUTH_PAGE); - } - }); + /** + * Обработчик клика по кнопке "Добавить пост"/"Войти". + * Если пользователь авторизован, перенаправляет на страницу добавления постов. + * Если пользователь не авторизован, перенаправляет на страницу авторизации. + */ + element + .querySelector('.add-or-login-button') + .addEventListener('click', () => { + if (user) { + goToPage(ADD_POSTS_PAGE); + } else { + goToPage(AUTH_PAGE); + } + }); - /** - * Обработчик клика по логотипу. - * Перенаправляет на страницу с постами. - */ - element.querySelector(".logo").addEventListener("click", () => { - goToPage(POSTS_PAGE); - }); + /** + * Обработчик клика по логотипу. + * Перенаправляет на страницу с постами. + */ + element.querySelector('.logo').addEventListener('click', () => { + goToPage(POSTS_PAGE); + }); - /** - * Обработчик клика по кнопке "Выйти". - * Если кнопка существует (т.е. пользователь авторизован), вызывает функцию `logout`. - */ - element.querySelector(".logout-button")?.addEventListener("click", logout); + /** + * Обработчик клика по кнопке "Выйти". + * Если кнопка существует (т.е. пользователь авторизован), вызывает функцию `logout`. + */ + element.querySelector('.logout-button')?.addEventListener('click', logout); - return element; + return element; } diff --git a/components/loading-page-component.js b/components/loading-page-component.js index c93522573..f42f40748 100644 --- a/components/loading-page-component.js +++ b/components/loading-page-component.js @@ -1,20 +1,20 @@ -import { renderHeaderComponent } from "./header-component.js"; +import { renderHeaderComponent } from './header-component.js'; /** * Компонент страницы загрузки. * Этот компонент отображает страницу с индикатором загрузки и заголовком. * Используется для отображения промежуточного состояния, пока выполняется загрузка данных или другой процесс. - * + * * @param {HTMLElement} params.appEl - Корневой элемент приложения, в который будет рендериться страница загрузки. * @param {Object} params.user - Объект пользователя, содержащий данные о текущем авторизованном пользователе (если он есть). * @param {Function} params.goToPage - Функция для навигации по страницам. */ export function renderLoadingPageComponent({ appEl, user, goToPage }) { - /** - * HTML-разметка страницы загрузки. - * Содержит контейнер заголовка и индикатор загрузки. - */ - const appHtml = ` + /** + * HTML-разметка страницы загрузки. + * Содержит контейнер заголовка и индикатор загрузки. + */ + const appHtml = `
@@ -22,16 +22,16 @@ export function renderLoadingPageComponent({ appEl, user, goToPage }) {
`; - // Устанавливаем разметку в корневой элемент приложения - appEl.innerHTML = appHtml; + // Устанавливаем разметку в корневой элемент приложения + appEl.innerHTML = appHtml; - /** - * Рендеринг заголовка с использованием компонента `renderHeaderComponent`. - * Передаются данные пользователя и функция навигации. - */ - renderHeaderComponent({ - user, - element: document.querySelector(".header-container"), - goToPage, - }); + /** + * Рендеринг заголовка с использованием компонента `renderHeaderComponent`. + * Передаются данные пользователя и функция навигации. + */ + renderHeaderComponent({ + user, + element: document.querySelector('.header-container'), + goToPage, + }); } diff --git a/components/posts-page-component.js b/components/posts-page-component.js index 662ccdbdc..2485839be 100644 --- a/components/posts-page-component.js +++ b/components/posts-page-component.js @@ -1,109 +1,133 @@ -import { USER_POSTS_PAGE } from "../routes.js"; -import { renderHeaderComponent } from "./header-component.js"; -import { posts, goToPage } from "../index.js"; - -export function renderPostsPageComponent({ appEl }) { - // @TODO: реализовать рендер постов из api - console.log("Актуальный список постов:", posts); - - /** - * @TODO: чтобы отформатировать дату создания поста в виде "19 минут назад" - * можно использовать https://date-fns.org/v2.29.3/docs/formatDistanceToNow - */ - const appHtml = ` -
-
- +
+ `; + }) + .join(''); + } else { + appHtml = ` +
+
+
+ `; + } - renderHeaderComponent({ - element: document.querySelector(".header-container"), - }); + appEl.innerHTML = appHtml; - for (let userEl of document.querySelectorAll(".post-header")) { - userEl.addEventListener("click", () => { - goToPage(USER_POSTS_PAGE, { - userId: userEl.dataset.userId, - }); + renderHeaderComponent({ + element: document.querySelector('.header-container'), }); - } + + for (let userEl of document.querySelectorAll('.post-header')) { + userEl.addEventListener('click', () => { + goToPage(USER_POSTS_PAGE, { + userId: userEl.dataset.userId, + }); + }); + } + + for (let btnElem of document.querySelectorAll('.like-button')) { + btnElem.addEventListener('click', () => { + const index = btnElem.dataset.postIndex; + const img = btnElem.querySelector('img'); + const countLikesElem = + document.querySelectorAll('.post-likes-text')[index]; + + likePost({ token }, posts[index].id, posts[index].isLiked).then( + (data) => { + posts[index].likes = data.likes; + posts[index].isLiked = data.isLiked; + + if (posts[index].isLiked) { + img.src = './assets/images/like-active.svg'; + } else { + img.src = './assets/images/like-not-active.svg'; + } + + countLikesElem.innerHTML = `Нравится: ${posts[index].likes.length}`; + }, + ); + }); + } + + for (let btnMenu of document.querySelectorAll('.post-header__menu-btn')) { + btnMenu.addEventListener('click', (event) => { + event.stopPropagation(); + + const index = btnMenu.dataset.index; + + if (posts[index].user.id === user._id) { + const menu = document.getElementById(`menu-${index}`); + menu.style.display = + menu.style.display === 'none' ? 'flex' : 'none'; + } + }); + } + + for (let btnDelete of document.querySelectorAll( + '.post-header__btn-delete', + )) { + btnDelete.addEventListener('click', (event) => { + event.stopPropagation(); + + if (user) { + const index = btnDelete.dataset.index; + const isConfirm = confirm('Вы подтверждаете удаление?'); + if (isConfirm) { + goToPage(DELETE_POST_PAGE, { postId: posts[index].id }); + } + } + }); + } } diff --git a/components/upload-image-component.js b/components/upload-image-component.js index e2cdf2018..f5df68c02 100644 --- a/components/upload-image-component.js +++ b/components/upload-image-component.js @@ -1,4 +1,4 @@ -import { uploadImage } from "../api.js"; +import { uploadImage } from '../api.js'; /** * Компонент загрузки изображения. @@ -10,30 +10,30 @@ import { uploadImage } from "../api.js"; * Принимает один аргумент - новый URL изображения или пустую строку. */ export function renderUploadImageComponent({ element, onImageUrlChange }) { - /** - * URL текущего изображения. - * Изначально пуст, пока пользователь не загрузит изображение. - * @type {string} - */ - let imageUrl = ""; + /** + * URL текущего изображения. + * Изначально пуст, пока пользователь не загрузит изображение. + * @type {string} + */ + let imageUrl = ''; - /** - * Функция рендеринга компонента. - * Отображает интерфейс компонента в зависимости от состояния: - * либо форма выбора файла, либо превью загруженного изображения с кнопкой замены. - */ - const render = () => { - element.innerHTML = ` + /** + * Функция рендеринга компонента. + * Отображает интерфейс компонента в зависимости от состояния: + * либо форма выбора файла, либо превью загруженного изображения с кнопкой замены. + */ + const render = () => { + element.innerHTML = `
${ - imageUrl - ? ` + imageUrl + ? `
Загруженное изображение
` - : ` + : `