diff --git a/README.md b/README.md index 34719e139..26c8b4ddf 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,115 @@ MVP социальной сети для обмена фотографиями ## Первоначальная оценка -ХХХХ часов +Много много часов ## Фактически затраченное время YYYY часов + +Сhecklist @TODO + +index.js +1) // @@TODO: реализовать получение постов юзера из API ГОТОВО + +2) // @TODO: реализовать добавление поста в API ГОТОВО + +3) // @TODO: реализовать страницу с фотографиями отдельного пользвателя ГОТОВО + +add-post-page-component.js +4) // @TODO: Реализовать страницу добавления поста ГОТОВО + +posts-page-component.js +5) // @TODO: реализовать рендер постов из api - ГОТОВО + +6) @TODO: чтобы отформатировать дату создания поста в виде "19 минут назад" можно использовать https://date-fns.org/v2.29.3/docs/formatDistanceToNow ГОТОВО + +Анализ архитектуры работы и последовательности действий + +Структура проекта +Project/ +|- api.js - работа с сервером (запросы GET, POST, загрузка файлов). +|- favicon.ico +|- helpers.js - вспомогательные функции (работа с LocalStorage для сохранения данных пользователя) +|- index.html - оболочка +|- index.js - управление рендером и навигацией @TODO #1-3 +|- README.md +|- routes.js - маршрутизатор +|- styles.css +|- ui-kit.css - стили кнопок инпутов и шрифтов +|- assets/ +| |- fonts/ +| | |- stratosskyengweb-medium.woff2 +| | |- stratosskyengweb-regular.woff2 +| |- images/ +| |- like-active.svg +| |- like-not-active.svg +|── components/ + |- add-post-page-component.js - создание нового поста @TODO #4 + |- auth-page-component.js - вход и регистрация + |- header-component.js - шапка (навигация, логин, кнопки входа и выхода) + |- loading-page-component.js - спинер загрузки данных с сервера + |- posts-page-component.js - главная страница, рендер постов @TODO #5 и 6 + |- upload-image-component.js - загрузка фоток + + КАК ЭТО РАБОТАЕТ + +1) index.html запускает index.js. +2) он в свою очередь определяет, на какой странице мы находимся (переменная page). Это либо: +- AUTH_PAGE - страница авторизации. +- ADD_POSTS_PAGE - добавление поста, +- POSTS_PAGE, основная страница ну или +- USER_POSTS_PAGE - профиль пользователя. +3) Если мы на POSTS_PAGE или USER_POSTS_PAGE то нужны данные пользователя или сами посты, тогда вызывается функция из api.js. +Пока мы ее ждем запускается LOADING_PAGE - спинер +4) Когда мы их получили, index.js вызывает нужный нам component/ +- AUTH_PAGE - renderAuthPageComponent +- ADD_POSTS_PAGE - renderAddPostPageComponent @TODO #2 +- POSTS_PAGE - renderPostsPageComponent +- USER_POSTS_PAGE - здесь заглушка @TODO #1 и 3 +5) И он уже генерирует HTML-строку и вставляет её в appEl.innerHTML внутри оболочки index.html. + +ИТАК ПЛАН РАБОТЫ +1) Пока лента статична я не увижу динамику Оживить ленту в posts-page-component.js +@TODO: реализовать рендер постов из api + +Удаляем статичный HTML сохранив шаблон post. Добавляем map для связи с массивом posts который приходит из index.js и превращаем массив в строку HTML. + +2) Профиль пользователя: +index.js +- @TODO: реализовать получение постов юзера из API +- @TODO: реализовать страницу с фотографиями отдельного пользвателя + +Добявляем в api.js функцию getUserPosts для получения постов от конкретного пользователя и отправляем ее в index.js + +Меняем логику получения постов для USER_POSTS_PAGE + +3) Интерфейс создания поста: +add-post-page-component.js +@TODO: Реализовать страницу добавления поста + +- Добавляем разметку страницы добавления поста +- Добовляем шапку +- И форму выбора фото с валидацией +- И О Чудо активировались страницы входа и регистрации + +4) Теперь публикация. Возвращаемся в index.js - @TODO: реализовать добавление поста в API + +В api добавляем функцию создания поста addPost +В index добавляем импорт из нее и вызываем функцию в ADD_POSTS_PAGE + + + +5) И в конце когда все будет работать мы можем внести правку в posts-page-component.js @TODO: чтобы отформатировать дату создания поста в виде "19 минут назад" можно использовать https://date-fns.org/v2.29.3/docs/formatDistanceToNow + +Подключил библиотеки для локолизации и форматирования даты публикации + +ФИНАЛ Доп задание - Лайки +Лайки можно ставить и удалять +api добавим функции POST и DELETE + +Импортируем функции в posts-page-component +Добовляем обработчик кликов +если не авторизован то ошибка в алерт + +ГОТОВО \ No newline at end of file diff --git a/api.js b/api.js index 123a9e3b6..4fa16545e 100644 --- a/api.js +++ b/api.js @@ -1,9 +1,28 @@ // Замени на свой, чтобы получить независимый от других набор данных. -// "боевая" версия инстапро лежит в ключе prod const personalKey = "prod"; const baseHost = "https://webdev-hw-api.vercel.app"; const postsHost = `${baseHost}/api/v1/${personalKey}/instapro`; +// Функция создания поста +export function addPost({ token, description, imageUrl }) { + return fetch(postsHost, { + method: "POST", + body: JSON.stringify({ + description, + imageUrl, + }), + headers: { + Authorization: token, + }, + }).then((response) => { + if (response.status === 400) { + throw new Error("Заполните описание и выберите картинку"); + } + return response.json(); + }); +} + +// Функция получения постов конкретного пользователя export function getPosts({ token }) { return fetch(postsHost, { method: "GET", @@ -15,7 +34,24 @@ export function getPosts({ token }) { if (response.status === 401) { throw new Error("Нет авторизации"); } + return response.json(); + }) + .then((data) => { + return data.posts; + }); +} +export function getUserPosts({ token, userId }) { + return fetch(postsHost + "/user-posts/" + userId, { + method: "GET", + headers: { + Authorization: token, + }, + }) + .then((response) => { + if (response.status === 401) { + throw new Error("Нет авторизации"); + } return response.json(); }) .then((data) => { @@ -55,6 +91,23 @@ export function loginUser({ login, password }) { }); } +// Ставим лайк +export function likePost({ token, postId }) { + return fetch(`${postsHost}/${postId}/like`, { + method: "POST", + headers: { Authorization: token } + }).then((response) => response.json()); +} + +// Удаляем лайк +export function dislikePost({ token, postId }) { + return fetch(`${postsHost}/${postId}/dislike`, { + method: "POST", // Странно но удаление тоже через POST + headers: { Authorization: token } + }).then((response) => response.json()); +} + + // Загружает картинку в облако, возвращает url загруженной картинки export function uploadImage({ file }) { const data = new FormData(); @@ -66,4 +119,4 @@ export function uploadImage({ file }) { }).then((response) => { return response.json(); }); -} +} \ No newline at end of file diff --git a/components/add-post-page-component.js b/components/add-post-page-component.js index 8277f093b..a6b6e996d 100644 --- a/components/add-post-page-component.js +++ b/components/add-post-page-component.js @@ -1,20 +1,70 @@ +import { renderHeaderComponent } from "./header-component.js"; +import { renderUploadImageComponent } from "./upload-image-component.js"; + export function renderAddPostPageComponent({ appEl, onAddPostClick }) { + // Переменная хранения ссылки на фото + let imageUrl = ""; + const render = () => { // @TODO: Реализовать страницу добавления поста + // Основная разметка страницы const appHtml = ` -
-
- Cтраница добавления поста - -
- `; +
+
+
+

Добавить пост

+
+
+ + + + +
+
+
+ `; 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; + }, + }); + } + + // Сбор данных и вызов callback-функции document.getElementById("add-button").addEventListener("click", () => { + const description = document.getElementById("description-input").value; + + // Валидация: картинка выбрана, описание заполнено + if (!imageUrl) { + alert("Пожалуйста, выберите фото"); + return; + } + if (!description) { + alert("Пожалуйста, добавьте описание"); + return; + } + + // Если всё ок, вызываем функцию из index.js onAddPostClick({ - description: "Описание картинки", - imageUrl: "https://image.png", + description: description, + imageUrl: imageUrl, }); }); }; diff --git a/components/posts-page-component.js b/components/posts-page-component.js index 662ccdbdc..3768bd68f 100644 --- a/components/posts-page-component.js +++ b/components/posts-page-component.js @@ -1,104 +1,115 @@ import { USER_POSTS_PAGE } from "../routes.js"; import { renderHeaderComponent } from "./header-component.js"; -import { posts, goToPage } from "../index.js"; +// Имортируем массив данных 'posts' и функцию навигации 'goToPage' +import { posts, user, goToPage } from "../index.js"; +import { likePost, dislikePost } from "../api.js"; +// Подключаем импорт из библиотеки date-fns для локализации +import formatDistanceToNow from + "https://cdn.jsdelivr.net/npm/date-fns@2.29.3/esm/formatDistanceToNow/index.js"; + +import ru from + "https://cdn.jsdelivr.net/npm/date-fns@2.29.3/esm/locale/ru/index.js"; export function renderPostsPageComponent({ appEl }) { - // @TODO: реализовать рендер постов из api - console.log("Актуальный список постов:", posts); + // Удоляем статику + const postsHtml = posts.map((post) => { + + // Вычисляем сколько времени прошло с момента создания поста до текущего + const createDate = post.createdAt + ? formatDistanceToNow(new Date(post.createdAt), { + addSuffix: true, + locale: ru, + }) + : "только что"; + + + return ` +
  • +
    + +

    ${post.user.name}

    +
    +
    + +
    +
    + +

    + Нравится: ${post.likes.length} +

    +
    +

    + ${post.user.name} + ${post.description} +

    +

    + ${createDate} +

    +
  • `; + }).join(""); // Превращаем массив строк в одну сплошную HTML-строку - /** - * @TODO: чтобы отформатировать дату создания поста в виде "19 минут назад" - * можно использовать https://date-fns.org/v2.29.3/docs/formatDistanceToNow - */ + // Вставляем сгенерированный список в общую оболочку страницы const appHtml = `
    `; appEl.innerHTML = appHtml; + // Шапка одна для всех страниц renderHeaderComponent({ element: document.querySelector(".header-container"), }); + // Обработчик кликов на лайки + for (let button of document.querySelectorAll(".like-button")) { + button.addEventListener("click", () => { + if (!user) { + alert("Чтобы поставить лайк, нужна аторизация"); + return; + } + + const postId = button.dataset.postId; + const post = posts.find((p) => p.id === postId); + + // Если лайк есть то удаляем или ставим + const apiFunction = post.isLiked ? dislikePost : likePost; + + const oldIsLiked = post.isLiked; + const oldLikes = [...post.likes]; + + // Перерисовываем страницу + renderPostsPageComponent({ appEl }); + + // Отправляем запрос на сервер + apiFunction({ + token: `Bearer ${user.token}`, + postId, + }) + .then((data) => { + const updatedPost = data.post || data; + // Сохраняем данные с сервера + post.likes = updatedPost.likes || []; + post.isLiked = updatedPost.isLiked; + // И опять перерисовываем + renderPostsPageComponent({ appEl }); + }) + .catch((error) => { + console.error("Ошибка при работе с лайком:", error); + // Если ошибка + renderPostsPageComponent({ appEl }); + alert("Не удалось обработать лайк."); + }); + }); + } + + // Обработчик кликов на заголовки постов + // goToPage при клике на заголовок дожны попасть в профиль автора for (let userEl of document.querySelectorAll(".post-header")) { userEl.addEventListener("click", () => { goToPage(USER_POSTS_PAGE, { @@ -106,4 +117,4 @@ export function renderPostsPageComponent({ appEl }) { }); }); } -} +} \ No newline at end of file diff --git a/index.js b/index.js index 7f1817c75..a8ef3c57c 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ -import { getPosts } from "./api.js"; +// Импортируем новую функцию getUserPosts +import { getPosts, getUserPosts, addPost } from "./api.js"; import { renderAddPostPageComponent } from "./components/add-post-page-component.js"; import { renderAuthPageComponent } from "./components/auth-page-component.js"; import { @@ -67,11 +68,31 @@ export const goToPage = (newPage, data) => { } if (newPage === USER_POSTS_PAGE) { - // @@TODO: реализовать получение постов юзера из API - console.log("Открываю страницу пользователя: ", data.userId); - page = USER_POSTS_PAGE; - posts = []; - return renderApp(); + // @TODO: реализовать получение постов юзера из API + + // Включаем спин + page = LOADING_PAGE; + renderApp(); + + // Вызываем API. ID пользователя мы берем из объекта data, + // который передается в goToPage при клике на аватарку + return getUserPosts({ + token: getToken(), + userId: data.userId + }) + .then((newPosts) => { + // Обновляем массив постов из API + posts = newPosts; + // Меняем статус страницы на "Профиль пользователя" + page = USER_POSTS_PAGE; + // И рисуем приложение с новыми данными + renderApp(); + }) + // Если ошибка то возвращаемся на главную страницу + .catch((error) => { + console.error(error); + goToPage(POSTS_PAGE); + }); } page = newPage; @@ -111,8 +132,26 @@ const renderApp = () => { appEl, onAddPostClick({ description, imageUrl }) { // @TODO: реализовать добавление поста в API - console.log("Добавляю пост...", { description, imageUrl }); - goToPage(POSTS_PAGE); + page = LOADING_PAGE; + renderApp(); + + // Вызываем API для добавления поста + addPost({ + token: getToken(), + description, + imageUrl, + }) + .then(() => { + // Возвращаемся в ленту + goToPage(POSTS_PAGE); + }) + .catch((error) => { + // Если ошибка то возвращаем форму и выводим ошибку + console.error(error); + alert(error.message); + page = ADD_POSTS_PAGE; + renderApp(); + }); }, }); } @@ -124,9 +163,11 @@ const renderApp = () => { } if (page === USER_POSTS_PAGE) { - // @TODO: реализовать страницу с фотографиями отдельного пользвателя - appEl.innerHTML = "Здесь будет страница фотографий пользователя"; - return; + // @TODO: реализовать страницу с фотографиями отдельного пользователя + // Меняем заглушку на реальную ленту + return renderPostsPageComponent({ + appEl, + }); } };