diff --git a/README.md b/README.md index 34719e139..9a8e4426e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,24 @@ -# Instapro +# Проект InstaPro 📸 -MVP социальной сети для обмена фотографиями +Учебный проект — аналог популярной социальной сети для обмена фотографиями. В ходе работы приложение было полностью переведено на динамическое взаимодействие с API, защищено от сбоев данных и оптимизировано для работы в различных режимах авторизации. -## Первоначальная оценка +## Реализованная функциональность -ХХХХ часов +- **Динамическая лента постов:** Реализован рендер всех публикаций пользователей, загружаемых с удаленного сервера. +- **Система лайков:** Добавлена возможность ставить и убирать лайки с мгновенным обновлением интерфейса (без перезагрузки всей страницы). +- **Относительные даты:** Время создания каждого поста переведено в удобный человекочитаемый формат (например, «19 минут назад», «3 часа назад»). +- **Защита от XSS-атак:** Внедрено экранирование HTML-тегов, блокирующее ввод вредоносного кода в описании постов. +- **Информирование об ошибках:** Добавлено корректное отображение ошибок («Неверный логин или пароль») прямо в интерфейсе формы авторизации при вводе некорректных данных. -## Фактически затраченное время +## Дополнительные фичи (Доп. задания) -YYYY часов +- **Гостевой режим для общей ленты:** Реализована возможность для неавторизованных пользователей просматривать главную страницу со всеми постами. +- **Гостевой режим для профилей:** Неавторизованные пользователи могут открывать и просматривать персональные ленты конкретных авторов. +- **Защита от несанкционированных действий:** Для гостей заблокирована отправка лайков — при клике на сердечко выводится предупреждающее уведомление без отправки некорректных запросов к API. +- **Защита от битых ссылок (Устойчивость бэкенда):** Внедрена строгая валидация URL-адресов картинок, которая перехватывает сломанные серверные ссылки (такие как фейковый домен `image.png`), предотвращая падение скриптов и забивание консоли ошибками `ERR_NAME_NOT_RESOLVED`. +- **Локальный модуль относительного времени:** Логика форматирования дат была написана вручную без утяжеления репозитория сторонними библиотеками, что исключило проблемы с блокировками CORS и внешних CDN. + +## Временные затраты + +- **Первоначальная оценка:** 12 часов. +- **Фактически затраченное время:** 29 часов (дополнительное время ушло на отладку сетевых ошибок, исправление некорректных данных с сервера и настройку локальной обработки дат). diff --git a/api.js b/api.js index 123a9e3b6..8934daa2f 100644 --- a/api.js +++ b/api.js @@ -1,6 +1,6 @@ // Замени на свой, чтобы получить независимый от других набор данных. // "боевая" версия инстапро лежит в ключе prod -const personalKey = "prod"; +const personalKey = "Tyryshkin2"; const baseHost = "https://webdev-hw-api.vercel.app"; const postsHost = `${baseHost}/api/v1/${personalKey}/instapro`; diff --git a/components/.gitignore b/components/.gitignore new file mode 100644 index 000000000..6e03962af --- /dev/null +++ b/components/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.DS_Store +dist/ \ No newline at end of file diff --git a/components/add-post-page-component.js b/components/add-post-page-component.js index 8277f093b..99ed240af 100644 --- a/components/add-post-page-component.js +++ b/components/add-post-page-component.js @@ -1,20 +1,69 @@ +import { renderUploadImageComponent } from "./upload-image-component.js"; export function renderAddPostPageComponent({ appEl, onAddPostClick }) { const render = () => { + // Переменная для динамического сохранения ссылки на загруженное фото + let imageUrl = ""; + // @TODO: Реализовать страницу добавления поста const appHtml = `
- Cтраница добавления поста - +
+

Добавить новый пост

+ + +
+ +
+

Опишите фотографию:

+ +
+ + +
`; appEl.innerHTML = appHtml; + // 1. Находим контейнер и запускаем в нем готовый компонент загрузки фото + const uploadImageContainer = document.querySelector( + ".upload-image-container", + ); + if (uploadImageContainer) { + renderUploadImageComponent({ + element: uploadImageContainer, + onImageUrlChange(newImageUrl) { + // Как только облако вернет ссылку на картинку, сохраняем её в переменную + imageUrl = newImageUrl; + }, + }); + } + + // 2. Навешиваем обработчик клика на кнопку отправки document.getElementById("add-button").addEventListener("click", () => { + const descriptionInput = document.getElementById("description-input"); + + // Простая валидация перед отправкой, чтобы бэкенд не ругался ошибками 400 + if (!imageUrl) { + alert("Пожалуйста, выберите и загрузите фотографию"); + return; + } + + if (!descriptionInput.value.trim()) { + alert("Пожалуйста, добавьте описание к посту"); + return; + } + + // Передаем наверх реальные данные вместо старых текстовых заглушек onAddPostClick({ - description: "Описание картинки", - imageUrl: "https://image.png", + description: descriptionInput.value, + imageUrl: imageUrl, }); }); }; diff --git a/components/date-fns-local.js b/components/date-fns-local.js new file mode 100644 index 000000000..39b172ceb --- /dev/null +++ b/components/date-fns-local.js @@ -0,0 +1,32 @@ +// Простая локальная замена formatDistanceToNow для русского языка +export function formatDistanceToNow(date) { + const now = new Date(); + const diffInSeconds = Math.floor((now - new Date(date)) / 1000); + + if (diffInSeconds < 0) return "только что"; + + const minutes = Math.floor(diffInSeconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + // Склонение числительных (минуты, часы, дни) + const getNoun = (number, one, two, five) => { + let n = Math.abs(number); + n %= 100; + if (n >= 5 && n <= 20) return five; + n %= 10; + if (n === 1) return one; + if (n >= 2 && n <= 4) return two; + return five; + }; + + if (minutes < 1) { + return "меньше минуты назад"; + } else if (minutes < 60) { + return `${minutes} ${getNoun(minutes, "минуту", "минуты", "минут")} назад`; + } else if (hours < 24) { + return `${hours} ${getNoun(hours, "час", "часа", "часов")} назад`; + } else { + return `${days} ${getNoun(days, "день", "дня", "дней")} назад`; + } +} diff --git a/components/posts-page-component.js b/components/posts-page-component.js index 662ccdbdc..ae773a578 100644 --- a/components/posts-page-component.js +++ b/components/posts-page-component.js @@ -1,6 +1,8 @@ import { USER_POSTS_PAGE } from "../routes.js"; import { renderHeaderComponent } from "./header-component.js"; -import { posts, goToPage } from "../index.js"; +import { formatDistanceToNow } from "./date-fns-local.js"; +// Добавили в деструктуризацию getToken для авторизации лайков и renderApp для обновления интерфейса +import { posts, goToPage, getToken, renderApp } from "../index.js"; export function renderPostsPageComponent({ appEl }) { // @TODO: реализовать рендер постов из api @@ -8,88 +10,69 @@ export function renderPostsPageComponent({ appEl }) { /** * @TODO: чтобы отформатировать дату создания поста в виде "19 минут назад" - * можно использовать https://date-fns.org/v2.29.3/docs/formatDistanceToNow + * можно использовать https://date-fns.org */ + const appHtml = `
`; @@ -106,4 +89,54 @@ export function renderPostsPageComponent({ appEl }) { }); }); } + + // ОБРАБОТКА ЛАЙКОВ: Навешиваем клики на все кнопки-сердечки + for (let likeBtnEl of document.querySelectorAll(".like-button")) { + likeBtnEl.addEventListener("click", () => { + // ПРОВЕРКА ДЛЯ НЕАВТОРИЗОВАННЫХ ПОЛЬЗОВАТЕЛЕЙ + // Если токена нет, сразу показываем ошибку и прерываем функцию (не отправляем fetch) + if (!getToken()) { + alert("Лайкать посты могут только зарегистрированные пользователи"); + return; + } + + const postId = likeBtnEl.dataset.postId; + const isLiked = likeBtnEl.dataset.isLiked === "true"; + const action = isLiked ? "dislike" : "like"; + + fetch( + `https://webdev-hw-api.vercel.app/api/v1/Tyryshkin2/instapro/${postId}/${action}`, + { + method: "POST", + headers: { + Authorization: getToken(), // Передаем токен авторизации + }, + }, + ) + .then((response) => { + if (response.status === 401) { + alert("Лайкать посты могут только зарегистрированные пользователи"); + throw new Error("Нет авторизации"); + } + if (response.status === 404) { + alert("Пост не найден"); + throw new Error("Пост не найден"); + } + return response.json(); + }) + .then((responseData) => { + // Находим измененный пост в глобальном массиве постов + const postIndex = posts.findIndex((p) => p.id === postId); + if (postIndex !== -1) { + // Подменяем старый объект обновленным объектом поста, который вернул сервер + posts[postIndex] = responseData.post; + } + // Мгновенно перерисовываем страницу приложения без полной перезагрузки окна + renderApp(); + }) + .catch((error) => { + console.error("Ошибка при обработке лайка:", error); + }); + }); + } } diff --git a/components/upload-image-component.js b/components/upload-image-component.js index e2cdf2018..bd3cdc159 100644 --- a/components/upload-image-component.js +++ b/components/upload-image-component.js @@ -19,7 +19,7 @@ export function renderUploadImageComponent({ element, onImageUrlChange }) { /** * Функция рендеринга компонента. - * Отображает интерфейс компонента в зависимости от состояния: + * Отображает интерфейс компонента в зависимости от состояния: * либо форма выбора файла, либо превью загруженного изображения с кнопкой замены. */ const render = () => { @@ -55,7 +55,7 @@ export function renderUploadImageComponent({ element, onImageUrlChange }) { const labelEl = document.querySelector(".file-upload-label"); labelEl.setAttribute("disabled", true); labelEl.textContent = "Загружаю файл..."; - + // Загружаем изображение с помощью API uploadImage({ file }).then(({ fileUrl }) => { imageUrl = fileUrl; // Сохраняем URL загруженного изображения @@ -75,6 +75,5 @@ export function renderUploadImageComponent({ element, onImageUrlChange }) { }); }; - // Инициализация компонента render(); } diff --git a/index.js b/index.js index 7f1817c75..a42bb339e 100644 --- a/index.js +++ b/index.js @@ -20,7 +20,7 @@ export let user = getUserFromLocalStorage(); export let page = null; export let posts = []; -const getToken = () => { +export const getToken = () => { const token = user ? `Bearer ${user.token}` : undefined; return token; }; @@ -67,11 +67,28 @@ export const goToPage = (newPage, data) => { } if (newPage === USER_POSTS_PAGE) { - // @@TODO: реализовать получение постов юзера из API - console.log("Открываю страницу пользователя: ", data.userId); - page = USER_POSTS_PAGE; - posts = []; - return renderApp(); + page = LOADING_PAGE; + renderApp(); + + return fetch( + `https://webdev-hw-api.vercel.app/api/v1/Tyryshkin2/instapro/user-posts/${data.userId}`, + { + method: "GET", + headers: { + Authorization: getToken(), + }, + }, + ) + .then((response) => response.json()) + .then((responseData) => { + page = USER_POSTS_PAGE; + posts = responseData.posts; + renderApp(); + }) + .catch((error) => { + console.error("Ошибка получения постов пользователя:", error); + goToPage(POSTS_PAGE); + }); } page = newPage; @@ -83,7 +100,15 @@ export const goToPage = (newPage, data) => { throw new Error("страницы не существует"); }; -const renderApp = () => { +function sanitizeHtml(string) { + return string + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +export const renderApp = () => { const appEl = document.getElementById("app"); if (page === LOADING_PAGE) { return renderLoadingPageComponent({ @@ -110,9 +135,32 @@ const renderApp = () => { return renderAddPostPageComponent({ appEl, onAddPostClick({ description, imageUrl }) { - // @TODO: реализовать добавление поста в API - console.log("Добавляю пост...", { description, imageUrl }); - goToPage(POSTS_PAGE); + // ИСПРАВЛЕНО: Теперь строка обрабатывается функцией очистки от тегов + const safeDescription = sanitizeHtml(description); + + fetch("https://webdev-hw-api.vercel.app/api/v1/Tyryshkin2/instapro", { + method: "POST", + body: JSON.stringify({ + description: safeDescription, // Отправляем экранированный текст + imageUrl, + }), + headers: { + Authorization: getToken(), + }, + }) + .then((response) => { + if (response.status === 400) { + throw new Error("Забыли описание или картинку"); + } + return response.json(); + }) + .then(() => { + goToPage(POSTS_PAGE); + }) + .catch((error) => { + console.error("Ошибка добавления поста:", error); + alert("Не удалось добавить post. Попробуйте снова."); + }); }, }); } @@ -124,9 +172,9 @@ const renderApp = () => { } if (page === USER_POSTS_PAGE) { - // @TODO: реализовать страницу с фотографиями отдельного пользвателя - appEl.innerHTML = "Здесь будет страница фотографий пользователя"; - return; + return renderPostsPageComponent({ + appEl, + }); } }; diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json new file mode 100644 index 000000000..5aab98c7e --- /dev/null +++ b/node_modules/.package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "webdev-cw-instapro", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..5aab98c7e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "webdev-cw-instapro", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{}