diff --git a/README.md b/README.md index 34719e139..cfad9f3e6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,18 @@ # Instapro MVP социальной сети для обмена фотографиями - +Удалось реализовать : +Анимации интерфейса +Плавные переходы между страницами +Анимация лайков при нажатии +Анимация ошибок формы +Индикаторы загрузки +Спиннер загрузки с анимацией +Визуальная обратная связь при действиях пользователя ## Первоначальная оценка -ХХХХ часов +18 часов ## Фактически затраченное время -YYYY часов +10 часов diff --git a/api.js b/api.js index 123a9e3b6..e905f003a 100644 --- a/api.js +++ b/api.js @@ -1,31 +1,94 @@ -// Замени на свой, чтобы получить независимый от других набор данных. -// "боевая" версия инстапро лежит в ключе prod +// api.js +// Базовый URL API из документации const personalKey = "prod"; -const baseHost = "https://webdev-hw-api.vercel.app"; +const baseHost = "https://wedev-api.sky.pro"; +// Адрес для работы с постами const postsHost = `${baseHost}/api/v1/${personalKey}/instapro`; +/** + * Получить список всех постов. + * @param {Object} params + * @param {string} [params.token] - Токен авторизации (для получения состояния isLiked). + * @returns {Promise} - Массив постов. + */ export function getPosts({ token }) { + // Заголовки запроса. Для GET не нужно указывать Content-Type. + const headers = {}; + + // Если токен предоставлен, добавляем его в заголовки + if (token) { + headers.Authorization = token; + } + return fetch(postsHost, { method: "GET", - headers: { - Authorization: token, - }, + headers, }) .then((response) => { - if (response.status === 401) { - throw new Error("Нет авторизации"); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); } + return response.json(); + }) + .then((data) => { + // API возвращает объект { posts: [...] } + return data.posts; + }); +} + +/** + * Получить посты конкретного пользователя. + * @param {Object} params + * @param {string} [params.token] - Токен авторизации (для получения состояния isLiked). + * @param {string} params.userId - ID пользователя. + * @returns {Promise} - Массив постов пользователя. + */ +export function getUserPosts({ token, userId }) { + // Заголовки запроса. Для GET не нужно указывать Content-Type. + const headers = {}; + + // Если токен предоставлен, добавляем его в заголовки + if (token) { + headers.Authorization = token; + } + // Формируем URL для получения постов конкретного пользователя + const url = `${postsHost}/user-posts/${userId}`; + + return fetch(url, { + method: "GET", + headers, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } return response.json(); }) .then((data) => { + // API возвращает объект { posts: [...] } return data.posts; }); } +/** + * Регистрация нового пользователя. + * @param {Object} params + * @param {string} params.login + * @param {string} params.password + * @param {string} params.name + * @param {string} params.imageUrl + * @returns {Promise} - Данные нового пользователя. + */ export function registerUser({ login, password, name, imageUrl }) { + // ВАЖНО: Для этого конкретного эндпоинта НЕ НУЖНО указывать Content-Type: application/json + // Согласно ошибке сервера: "В заголовке передан content-type: application/json, но эта API не умеет работать с этим заголовком, уберите его" return fetch(baseHost + "/api/user", { method: "POST", + // Убираем "Content-Type": "application/json", + headers: { + // Никаких заголовков, кроме стандартных для JSON, не нужно + }, body: JSON.stringify({ login, password, @@ -34,36 +97,189 @@ export function registerUser({ login, password, name, imageUrl }) { }), }).then((response) => { if (response.status === 400) { - throw new Error("Такой пользователь уже существует"); + // Обрабатываем ошибку 400 (например, пользователь уже существует) + // Получаем сообщение об ошибке от сервера + return response.json().then((errorData) => { + // Используем сообщение от сервера, если оно есть, иначе дефолтное + throw new Error(errorData?.error || "Такой пользователь уже существует"); + }); + } + if (!response.ok) { + // Обрабатываем другие HTTP ошибки + throw new Error("Ошибка при регистрации"); } + // Если всё ОК, возвращаем данные пользователя return response.json(); }); } +/** + * Авторизация пользователя. + * @param {Object} params + * @param {string} params.login + * @param {string} params.password + * @returns {Promise} - Данные авторизованного пользователя. + */ export function loginUser({ login, password }) { + // ВАЖНО: Для этого конкретного эндпоинта НЕ НУЖНО указывать Content-Type: application/json + // Согласно ошибке сервера: "В заголовке передан content-type: application/json, но эта API не умеет работать с этим заголовком, уберите его" return fetch(baseHost + "/api/user/login", { method: "POST", + // Убираем "Content-Type": "application/json", + headers: { + // Никаких заголовков, кроме стандартных для JSON, не нужно + }, body: JSON.stringify({ login, password, }), }).then((response) => { if (response.status === 400) { - throw new Error("Неверный логин или пароль"); + // Обрабатываем ошибку 400 (например, неверный логин или пароль) + // Получаем сообщение об ошибке от сервера + return response.json().then((errorData) => { + // Используем сообщение от сервера, если оно есть, иначе дефолтное + throw new Error(errorData?.error || "Неверный логин или пароль"); + }); + } + if (!response.ok) { + // Обрабатываем другие HTTP ошибки + throw new Error("Ошибка при входе"); + } + // Если всё ОК, возвращаем данные пользователя + return response.json(); + }); +} + +/** + * Добавление нового поста. + * @param {Object} params + * @param {string} params.token - Токен авторизации (обязательно). + * @param {string} params.description - Описание поста. + * @param {string} params.imageUrl - URL изображения. + * @returns {Promise} - Результат операции. + */ +export function addPost({ token, description, imageUrl }) { + console.log("API: Отправляем данные на сервер:", { description, imageUrl }); + + // ВАЖНО: Для этого конкретного эндпоинта НЕ НУЖНО указывать Content-Type: application/json + // Согласно ошибке сервера: "В заголовке передан content-type: application/json, но эта API не умеет работать с этим заголовком, уберите его" + return fetch(postsHost, { + method: "POST", + headers: { + // Убираем "Content-Type": "application/json", + // Токен авторизации обязателен + Authorization: token, + }, + body: JSON.stringify({ + description: description || "", + imageUrl: imageUrl || "", + }), + }).then((response) => { + if (response.status === 401) { + // Обрабатываем ошибку 401 (нет авторизации) + throw new Error("Нет авторизации"); + } + if (response.status === 400) { + // Обрабатываем ошибку 400 (например, не переданы обязательные данные) + // Получаем детальное сообщение об ошибке от сервера + return response.json().then((errorData) => { + console.error("API: Ошибка 400 от сервера:", errorData); + // Используем сообщение от сервера, если оно есть, иначе дефолтное + throw new Error(errorData?.error || "Не переданы обязательные данные"); + }); } + if (!response.ok) { + // Обрабатываем другие HTTP ошибки + throw new Error("Ошибка при добавлении поста"); + } + // Если всё ОК, возвращаем результат return response.json(); }); } -// Загружает картинку в облако, возвращает url загруженной картинки +/** + * Загрузка изображения. + * @param {Object} params + * @param {File} params.file - Файл изображения. + * @returns {Promise} - Данные загрузки (включая URL). + */ export function uploadImage({ file }) { + // Для загрузки файлов используется FormData, Content-Type НЕ нужен + // (браузер сам установит правильный Content-Type с boundary) const data = new FormData(); data.append("file", file); return fetch(baseHost + "/api/upload/image", { method: "POST", body: data, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then((uploadResult) => { + console.log("API: Полный ответ от uploadImage:", uploadResult); + return uploadResult; + }); +} + +/** + * Поставить лайк посту. + * @param {Object} params + * @param {string} params.token - Токен авторизации (обязательно). + * @param {string} params.postId - ID поста. + * @returns {Promise} - Обновленный пост. + */ +export function likePost({ token, postId }) { + // Для POST без тела заголовок Content-Type НЕ нужен + return fetch(`${postsHost}/${postId}/like`, { + method: "POST", + headers: { + // Токен авторизации обязателен + Authorization: token, + }, + }).then((response) => { + if (response.status === 401) { + // Обрабатываем ошибку 401 (нет авторизации) + throw new Error("Нет авторизации"); + } + if (!response.ok) { + // Обрабатываем другие HTTP ошибки + throw new Error("Ошибка при лайке"); + } + // Если всё ОК, возвращаем обновленный пост + return response.json(); + }); +} + +/** + * Убрать лайк с поста. + * @param {Object} params + * @param {string} params.token - Токен авторизации (обязательно). + * @param {string} params.postId - ID поста. + * @returns {Promise} - Обновленный пост. + */ +export function dislikePost({ token, postId }) { + // Для POST без тела заголовок Content-Type НЕ нужен + return fetch(`${postsHost}/${postId}/dislike`, { + method: "POST", + headers: { + // Токен авторизации обязателен + Authorization: token, + }, }).then((response) => { + if (response.status === 401) { + // Обрабатываем ошибку 401 (нет авторизации) + throw new Error("Нет авторизации"); + } + if (!response.ok) { + // Обрабатываем другие HTTP ошибки + throw new Error("Ошибка при дизлайке"); + } + // Если всё ОК, возвращаем обновленный пост return response.json(); }); } diff --git a/assets/images/default-image.jpg b/assets/images/default-image.jpg new file mode 100644 index 000000000..c2e6d817b Binary files /dev/null and b/assets/images/default-image.jpg differ diff --git a/components/add-post-page-component.js b/components/add-post-page-component.js index 8277f093b..0360e5720 100644 --- a/components/add-post-page-component.js +++ b/components/add-post-page-component.js @@ -1,23 +1,136 @@ +// components/add-post-page-component.js +import { renderUploadImageComponent } from "./upload-image-component.js"; +// Импортируем getToken для дополнительной проверки (не обязательно, так как проверка есть в index.js) +// import { getToken } from "../index.js"; // Можно убрать, если не используется напрямую здесь + +/** + * Рендер компонента страницы добавления поста. + * @param {Object} params - Параметры компонента. + * @param {HTMLElement} params.appEl - Корневой элемент приложения. + * @param {Function} params.onAddPostClick - Callback для обработки клика по кнопке "Добавить". + */ export function renderAddPostPageComponent({ appEl, onAddPostClick }) { - const render = () => { - // @TODO: Реализовать страницу добавления поста - const appHtml = ` + // Переменная для хранения URL загруженного изображения + let currentImageUrl = ""; + + // HTML разметка компонента + const appHtml = `
- Cтраница добавления поста - +
+

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

+
+
+
+
+ + + +
+ +
`; - appEl.innerHTML = appHtml; + // Вставляем HTML в корневой элемент + appEl.innerHTML = appHtml; - document.getElementById("add-button").addEventListener("click", () => { - onAddPostClick({ - description: "Описание картинки", - imageUrl: "https://image.png", - }); - }); + // Получаем элементы DOM + const uploadContainerElement = document.getElementById("upload-image-container"); + const addButton = document.getElementById("add-button"); + const descriptionElement = document.getElementById("post-description"); + const errorElement = document.getElementById("form-error"); + + // Проверяем существование всех необходимых элементов + if (!uploadContainerElement || !addButton || !descriptionElement || !errorElement) { + console.error("AddPostComponent: Один или несколько необходимых элементов форм не найдены."); + // Можно отобразить общую ошибку, если критично + // if (errorElement) { + // errorElement.style.display = 'block'; + // errorElement.textContent = "Ошибка инициализации формы."; + // } + return; + } + + // --- Логика отображения/скрытия ошибки --- + /** + * Показать сообщение об ошибке. + * @param {string} message - Текст сообщения об ошибке. + */ + const showError = (message) => { + errorElement.textContent = message; + errorElement.style.display = 'block'; // Делаем элемент видимым + }; + + /** + * Скрыть сообщение об ошибке. + */ + const hideError = () => { + errorElement.textContent = ""; + errorElement.style.display = 'none'; // Скрываем элемент }; - render(); + // --- Рендер компонента загрузки изображения --- + renderUploadImageComponent({ + element: uploadContainerElement, + // Callback, который вызывается при изменении URL изображения + onImageUrl: (imageUrl) => { + console.log("AddPostComponent: URL изображения обновлен:", imageUrl); + // Обновляем переменную с URL изображения + currentImageUrl = imageUrl; + + // Если изображение загружено и ранее была ошибка "Загрузите изображение", скрываем её + // Также скрываем ошибку при любой успешной загрузке, если форма была отправлена с этой ошибкой + if (currentImageUrl && errorElement.textContent === "Загрузите изображение") { + hideError(); + } + }, + }); + + // --- Обработчики событий для полей ввода --- + // Добавляем обработчик события input для textarea описания + descriptionElement.addEventListener("input", () => { + // Если пользователь начал вводить текст и ранее была ошибка "Введите описание поста", скрываем её + if (descriptionElement.value.trim() !== "" && errorElement.textContent === "Введите описание поста") { + hideError(); + } + }); + + // --- Обработчик события для кнопки "Добавить" --- + // Добавляем обработчик события click для кнопки "Добавить" + addButton.addEventListener("click", () => { + // Получаем описание и убираем лишние пробелы + const description = descriptionElement.value.trim(); + + // --- Проверка валидности формы --- + // Сначала скрываем любую предыдущую ошибку + hideError(); + + // Проверяем обязательные поля + if (!description) { + showError("Введите описание поста"); + // Фокус на поле описания для удобства + descriptionElement.focus(); + return; + } + + if (!currentImageUrl) { + showError("Загрузите изображение"); + // Фокус на область загрузки изображения (или кнопку) может быть сложнее, пропустим + return; + } + + // --- Вызов обработчика из index.js --- + // Если все проверки пройдены, вызываем переданную функцию обработки клика + // Проверка токена и других действий происходит в index.js + onAddPostClick({ + description, + imageUrl: currentImageUrl, + }); + }); } diff --git a/components/auth-page-component.js b/components/auth-page-component.js index 0e93530ec..19513ad8c 100644 --- a/components/auth-page-component.js +++ b/components/auth-page-component.js @@ -1,17 +1,17 @@ +// components/auth-page-component.js import { loginUser, registerUser } from "../api.js"; import { renderHeaderComponent } from "./header-component.js"; import { renderUploadImageComponent } from "./upload-image-component.js"; /** - * Компонент страницы авторизации. - * Этот компонент предоставляет пользователю интерфейс для входа в систему или регистрации. - * Форма переключается между режимами "Вход" и "Регистрация". - * - * @param {HTMLElement} params.appEl - Корневой элемент приложения, в который будет рендериться страница. - * @param {Function} params.setUser - Функция, вызываемая при успешной авторизации или регистрации. - * Принимает объект пользователя в качестве аргумента. + * Компонент страницы авторизации/регистрации. + * @param {Object} params - Параметры компонента. + * @param {HTMLElement} params.appEl - Корневой элемент приложения. + * @param {Function} params.setUser - Функция для установки данных пользователя после успешного входа/регистрации. + * @param {Object|null} params.user - Текущий пользователь (не используется напрямую здесь, но передается в header). + * @param {Function} params.goToPage - Функция для навигации между страницами. */ -export function renderAuthPageComponent({ appEl, setUser }) { +export function renderAuthPageComponent({ appEl, setUser, user, goToPage }) { /** * Флаг, указывающий текущий режим формы. * Если `true`, форма находится в режиме входа. Если `false`, в режиме регистрации. @@ -46,14 +46,16 @@ export function renderAuthPageComponent({ appEl, setUser }) { ${ !isLoginMode ? ` -
+
+
+
` : "" } -
+ @@ -73,98 +75,189 @@ export function renderAuthPageComponent({ appEl, setUser }) { appEl.innerHTML = appHtml; /** - * Устанавливает сообщение об ошибке в форме. + * Показать сообщение об ошибке. * @param {string} message - Текст сообщения об ошибке. */ - const setError = (message) => { - appEl.querySelector(".form-error").textContent = message; + const showError = (message) => { + const errorElement = appEl.querySelector(".form-error"); + if (errorElement) { + errorElement.textContent = message; + errorElement.style.display = 'block'; + } + }; + + /** + * Скрыть сообщение об ошибке. + */ + const hideError = () => { + const errorElement = appEl.querySelector(".form-error"); + if (errorElement) { + errorElement.textContent = ""; + errorElement.style.display = 'none'; + } }; // Рендерим заголовок страницы renderHeaderComponent({ element: document.querySelector(".header-container"), + user, // Передаем текущего пользователя (может быть null) + goToPage, // Передаем функцию навигации }); // Если режим регистрации, рендерим компонент загрузки изображения const uploadImageContainer = appEl.querySelector(".upload-image-container"); - if (uploadImageContainer) { - renderUploadImageComponent({ - element: uploadImageContainer, - onImageUrlChange(newImageUrl) { - imageUrl = newImageUrl; - }, - }); + if (uploadImageContainer && !isLoginMode) { + // Исправлен ID контейнера (был опечатка: "conrainer" вместо "container") + const uploadImageInnerContainer = appEl.querySelector("#upload-image-conrainer"); + if (uploadImageInnerContainer) { + renderUploadImageComponent({ + element: uploadImageInnerContainer, + onImageUrl: (newImageUrl) => { // Используем правильное имя параметра + console.log("AuthPage: URL изображения обновлен:", newImageUrl); + imageUrl = newImageUrl; + + // Если изображение загружено и была ошибка "Не выбрана фотография", скрываем её + const errorElement = appEl.querySelector(".form-error"); + if (imageUrl && errorElement && errorElement.textContent === "Не выбрана фотография") { + hideError(); + } + }, + }); + } } // Обработка клика на кнопку входа/регистрации - document.getElementById("login-button").addEventListener("click", () => { - setError(""); + const loginButton = document.getElementById("login-button"); + if (loginButton) { + loginButton.addEventListener("click", () => { + hideError(); // Скрываем предыдущие ошибки - if (isLoginMode) { - // Обработка входа - const login = document.getElementById("login-input").value; - const password = document.getElementById("password-input").value; + if (isLoginMode) { + // Обработка входа + const login = document.getElementById("login-input")?.value?.trim(); + const password = document.getElementById("password-input")?.value?.trim(); - if (!login) { - alert("Введите логин"); - return; - } + if (!login) { + showError("Введите логин"); + return; + } - if (!password) { - alert("Введите пароль"); - return; - } + if (!password) { + showError("Введите пароль"); + 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; - } + loginUser({ login, password }) + .then((userData) => { + console.log("AuthPage: Успешный вход:", userData); + setUser(userData.user); // Устанавливаем пользователя из ответа API + }) + .catch((error) => { + console.warn("AuthPage: Ошибка входа:", error); + showError(error.message); + }); + } else { + // Обработка регистрации + const login = document.getElementById("login-input")?.value?.trim(); + const name = document.getElementById("name-input")?.value?.trim(); + const password = document.getElementById("password-input")?.value?.trim(); - if (!login) { - alert("Введите логин"); - return; - } + if (!name) { + showError("Введите имя"); + return; + } - if (!password) { - alert("Введите пароль"); - return; - } + if (!login) { + showError("Введите логин"); + return; + } - if (!imageUrl) { - alert("Не выбрана фотография"); - return; - } + if (!password) { + showError("Введите пароль"); + return; + } - registerUser({ login, password, name, imageUrl }) - .then((user) => { - setUser(user.user); - }) - .catch((error) => { - console.warn(error); - setError(error.message); - }); - } - }); + if (!imageUrl) { + showError("Не выбрана фотография"); + return; + } + + registerUser({ login, password, name, imageUrl }) + .then((userData) => { + console.log("AuthPage: Успешная регистрация:", userData); + setUser(userData.user); // Устанавливаем пользователя из ответа API + }) + .catch((error) => { + console.warn("AuthPage: Ошибка регистрации:", error); + showError(error.message); + }); + } + }); + } // Обработка переключения режима (вход ↔ регистрация) - document.getElementById("toggle-button").addEventListener("click", () => { - isLoginMode = !isLoginMode; - renderForm(); // Перерисовываем форму с новым режимом - }); + const toggleButton = document.getElementById("toggle-button"); + if (toggleButton) { + toggleButton.addEventListener("click", () => { + isLoginMode = !isLoginMode; + renderForm(); // Перерисовываем форму с новым режимом + }); + } + + // --- Добавляем обработчики для скрытия ошибок при вводе --- + if (!isLoginMode) { + // Режим регистрации + const nameInput = document.getElementById("name-input"); + const loginInputReg = document.getElementById("login-input"); + const passwordInputReg = document.getElementById("password-input"); + + if (nameInput) { + nameInput.addEventListener("input", () => { + const errorElement = appEl.querySelector(".form-error"); + if (errorElement && errorElement.textContent === "Введите имя") { + hideError(); + } + }); + } + if (loginInputReg) { + loginInputReg.addEventListener("input", () => { + const errorElement = appEl.querySelector(".form-error"); + if (errorElement && errorElement.textContent === "Введите логин") { + hideError(); + } + }); + } + if (passwordInputReg) { + passwordInputReg.addEventListener("input", () => { + const errorElement = appEl.querySelector(".form-error"); + if (errorElement && errorElement.textContent === "Введите пароль") { + hideError(); + } + }); + } + // Обработчик для ошибки "Не выбрана фотография" уже добавлен выше в onImageUrl + } else { + // Режим входа + const loginInputLogin = document.getElementById("login-input"); + const passwordInputLogin = document.getElementById("password-input"); + + if (loginInputLogin) { + loginInputLogin.addEventListener("input", () => { + const errorElement = appEl.querySelector(".form-error"); + if (errorElement && errorElement.textContent === "Введите логин") { + hideError(); + } + }); + } + if (passwordInputLogin) { + passwordInputLogin.addEventListener("input", () => { + const errorElement = appEl.querySelector(".form-error"); + if (errorElement && errorElement.textContent === "Введите пароль") { + hideError(); + } + }); + } + } }; // Инициализация формы diff --git a/components/header-component.js b/components/header-component.js index e733a39dc..c57e34229 100644 --- a/components/header-component.js +++ b/components/header-component.js @@ -1,17 +1,7 @@ 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 .querySelector(".add-or-login-button") .addEventListener("click", () => { @@ -45,18 +30,10 @@ export function renderHeaderComponent({ element }) { } }); - /** - * Обработчик клика по логотипу. - * Перенаправляет на страницу с постами. - */ element.querySelector(".logo").addEventListener("click", () => { goToPage(POSTS_PAGE); }); - /** - * Обработчик клика по кнопке "Выйти". - * Если кнопка существует (т.е. пользователь авторизован), вызывает функцию `logout`. - */ element.querySelector(".logout-button")?.addEventListener("click", logout); return element; diff --git a/components/loading-page-component.js b/components/loading-page-component.js index c93522573..974773d22 100644 --- a/components/loading-page-component.js +++ b/components/loading-page-component.js @@ -1,37 +1,7 @@ -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 = ` -
-
-
-
-
-
`; - - // Устанавливаем разметку в корневой элемент приложения - appEl.innerHTML = appHtml; - - /** - * Рендеринг заголовка с использованием компонента `renderHeaderComponent`. - * Передаются данные пользователя и функция навигации. - */ - renderHeaderComponent({ - user, - element: document.querySelector(".header-container"), - goToPage, - }); +export function renderLoadingPageComponent({ appEl }) { + const html = ` +
+
+
`; + appEl.innerHTML = html; } diff --git a/components/posts-page-component.js b/components/posts-page-component.js index 662ccdbdc..f9e3f6ceb 100644 --- a/components/posts-page-component.js +++ b/components/posts-page-component.js @@ -1,109 +1,185 @@ 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); +function simpleFormatDate(dateString) { + try { + const date = new Date(dateString); + const now = new Date(); + const timeDiff = now - date; + const minutes = Math.floor(timeDiff / (1000 * 60)); + const hours = Math.floor(timeDiff / (1000 * 60 * 60)); + const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24)); + + if (minutes < 1) { + return "менее минуты назад"; + } else if (minutes < 60) { + return `${minutes} ${getMinutesWord(minutes)} назад`; + } else if (hours < 24) { + return `${hours} ${getHoursWord(hours)} назад`; + } else { + return `${days} ${getDaysWord(days)} назад`; + } + } catch (e) { + return "недавно"; + } +} + +function getMinutesWord(minutes) { + const lastDigit = minutes % 10; + const lastTwoDigits = minutes % 100; + + if (lastTwoDigits >= 11 && lastTwoDigits <= 19) { + return "минут"; + } + + switch (lastDigit) { + case 1: return "минуту"; + case 2: + case 3: + case 4: return "минуты"; + default: return "минут"; + } +} + +function getHoursWord(hours) { + const lastDigit = hours % 10; + const lastTwoDigits = hours % 100; + + if (lastTwoDigits >= 11 && lastTwoDigits <= 19) { + return "часов"; + } + + switch (lastDigit) { + case 1: return "час"; + case 2: + case 3: + case 4: return "часа"; + default: return "часов"; + } +} + +function getDaysWord(days) { + const lastDigit = days % 10; + const lastTwoDigits = days % 100; + + if (lastTwoDigits >= 11 && lastTwoDigits <= 19) { + return "дней"; + } + + switch (lastDigit) { + case 1: return "день"; + case 2: + case 3: + case 4: return "дня"; + default: return "дней"; + } +} + +export function renderPostsPageComponent({ appEl, posts, user, goToPage, toggleLike, isUserPostsPage = false }) { + + let pageHeader = ''; + if (isUserPostsPage && posts.length > 0) { + const firstPost = posts[0]; + pageHeader = ` +
+ +
${firstPost.user.name}
+
+ `; + } + + const postsHtml = posts.map((post) => { + const isLiked = post.isLiked; + const likeImage = isLiked + ? "./assets/images/like-active.svg" + : "./assets/images/like-not-active.svg"; + + const imageSrc = post.imageUrl && post.imageUrl.trim() !== '' + ? post.imageUrl + : "./assets/images/default-image.jpg"; + + return ` +
  • +
    + +

    ${post.user.name}

    +
    +
    + +
    +
    + +

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

    +
    +

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

    + +
  • + `; + }).join(""); - /** - * @TODO: чтобы отформатировать дату создания поста в виде "19 минут назад" - * можно использовать https://date-fns.org/v2.29.3/docs/formatDistanceToNow - */ const appHtml = ` -
    -
    -
      -
    • -
      - -

      Иван Иваныч

      -
      -
      - -
      -
      - -

      - Нравится: 2 -

      -
      -

      - Иван Иваныч - Ромашка, ромашка... -

      - -
    • -
    • -
      - -

      Варварва Н.

      -
      - - -
      - -
      -
      - -

      - Нравится: 35 -

      -
      -

      - Варварва Н. - Нарисовала картину, посмотрите какая красивая -

      - -
    • -
    • -
      - -

      Варварва Н.

      -
      - - -
      - -
      -
      - -

      - Нравится: 0 -

      -
      -

      - Варварва Н. - Голова -

      - -
    • -
    -
    `; +
    +
    + ${pageHeader} +
      + ${postsHtml || '

      Постов пока нет

      '} +
    +
    + `; appEl.innerHTML = appHtml; renderHeaderComponent({ element: document.querySelector(".header-container"), + user, + goToPage, }); - for (let userEl of document.querySelectorAll(".post-header")) { + document.querySelectorAll(".post-header").forEach(userEl => { userEl.addEventListener("click", () => { - goToPage(USER_POSTS_PAGE, { - userId: userEl.dataset.userId, - }); + const userId = userEl.dataset.userId; + if (userId) { + goToPage(USER_POSTS_PAGE, { + userId: userEl.dataset.userId, + }); + } + }); + }); + + document.querySelectorAll('.like-button').forEach(button => { + button.addEventListener('click', (event) => { + event.stopPropagation(); + + button.classList.add('liked'); + setTimeout(() => { + button.classList.remove('liked'); + }, 300); + + const postId = button.dataset.postId; + const isLiked = button.dataset.isLiked === 'true'; + + button.dataset.isLiked = !isLiked; + + if (typeof toggleLike === 'function') { + toggleLike(postId, isLiked); + } }); + }); + + const container = document.querySelector('.page-container'); + if (container) { + container.classList.add('page-transition'); + setTimeout(() => { + container.classList.add('active'); + }, 10); } } diff --git a/components/upload-image-component.js b/components/upload-image-component.js index e2cdf2018..5d00f78e5 100644 --- a/components/upload-image-component.js +++ b/components/upload-image-component.js @@ -1,80 +1,144 @@ +// components/upload-image-component.js import { uploadImage } from "../api.js"; /** - * Компонент загрузки изображения. - * Этот компонент позволяет пользователю загружать изображение и отображать его превью. - * Если изображение уже загружено, пользователь может заменить его. - * - * @param {HTMLElement} params.element - HTML-элемент, в который будет рендериться компонент. - * @param {Function} params.onImageUrlChange - Функция, вызываемая при изменении URL изображения. - * Принимает один аргумент - новый URL изображения или пустую строку. + * Рендерит компонент загрузки изображения. + * @param {Object} params - Параметры компонента. + * @param {HTMLElement} params.element - DOM-элемент, в который будет отрендерен компонент. + * @param {Function} params.onImageUrl - Callback, вызываемый при изменении URL изображения. + * Принимает новый URL изображения в качестве аргумента. */ -export function renderUploadImageComponent({ element, onImageUrlChange }) { - /** - * URL текущего изображения. - * Изначально пуст, пока пользователь не загрузит изображение. - * @type {string} - */ +export function renderUploadImageComponent({ element, onImageUrl }) { + // Проверяем, что переданный элемент существует + if (!element) { + console.error("UploadImageComponent: Не передан DOM-элемент для рендеринга."); + return; + } + + // Переменная для хранения URL загруженного изображения let imageUrl = ""; - /** - * Функция рендеринга компонента. - * Отображает интерфейс компонента в зависимости от состояния: - * либо форма выбора файла, либо превью загруженного изображения с кнопкой замены. - */ + // Функция для рендеринга компонента const render = () => { - element.innerHTML = ` + // HTML разметка компонента в зависимости от того, загружено ли изображение + const html = `
    - ${ - imageUrl - ? ` -
    - Загруженное изображение - -
    - ` - : ` - + ${imageUrl + ? ` +
    + Загруженное изображение + +
    ` - } + : ` + + `}
    `; - // Обработчик выбора файла - const fileInputElement = element.querySelector(".file-upload-input"); - fileInputElement?.addEventListener("change", () => { - const file = fileInputElement.files[0]; - if (file) { - const labelEl = document.querySelector(".file-upload-label"); - labelEl.setAttribute("disabled", true); - labelEl.textContent = "Загружаю файл..."; - - // Загружаем изображение с помощью API - uploadImage({ file }).then(({ fileUrl }) => { - imageUrl = fileUrl; // Сохраняем URL загруженного изображения - onImageUrlChange(imageUrl); // Уведомляем о изменении URL изображения - render(); // Перерисовываем компонент с новым состоянием - }); - } - }); + // Вставляем HTML в переданный элемент + element.innerHTML = html; + + // Получаем input для выбора файла + const fileInput = element.querySelector(".file-upload-input"); + if (fileInput) { + // Добавляем обработчик события change для input + // Используем 'input' или 'change', оба работают, 'change' стандартнее для файлов + fileInput.addEventListener("change", async (event) => { + // Получаем выбранный файл + const files = event.target.files; + if (files && files.length > 0) { + const file = files[0]; + // Получаем label для отображения состояния загрузки + const label = element.querySelector(".file-upload-label"); + if (label) { + // Блокируем label и меняем текст на "Загрузка..." + label.setAttribute("disabled", "true"); + label.textContent = "Загрузка..."; + } + + try { + console.log("UploadComponent: Начинаем загрузку файла:", file.name); + // Вызываем API для загрузки изображения + const uploadResult = await uploadImage({ file }); + console.log("UploadComponent: Ответ от uploadImage:", uploadResult); + + // Извлекаем URL изображения из ответа API + // Согласно API документации, сервер возвращает объект с полем 'fileUrl'. + // Также добавлена проверка, что uploadResult - объект. + if (uploadResult && typeof uploadResult === 'object' && uploadResult.fileUrl) { + imageUrl = uploadResult.fileUrl; // Используем конкретное поле, как в API документации + } else { + // Если fileUrl нет, попробуем другие возможные поля (на случай изменений API) + imageUrl = uploadResult?.imageUrl || uploadResult?.url || ""; + // Если URL всё ещё пустой, это может быть признаком ошибки, даже если промис зарезолвился + if (!imageUrl) { + console.warn("UploadComponent: URL изображения не найден в ответе API.", uploadResult); + throw new Error("URL изображения отсутствует в ответе сервера."); + } + } + console.log("UploadComponent: Извлеченный imageUrl:", imageUrl); + + // Проверяем тип onImageUrl перед вызовом + if (typeof onImageUrl === 'function') { + // Вызываем callback с новым URL + onImageUrl(imageUrl); + } else { + console.warn("UploadComponent: onImageUrl не является функцией."); + } + + // Перерендериваем компонент для отображения загруженного изображения + render(); + } catch (error) { + console.error("UploadComponent: Ошибка загрузки:", error); - // Обработчик удаления изображения - element - .querySelector(".file-upload-remove-button") - ?.addEventListener("click", () => { - imageUrl = ""; // Сбрасываем URL изображения - onImageUrlChange(imageUrl); // Уведомляем об изменении URL изображения - render(); // Перерисовываем компонент + // Вызываем callback с пустой строкой в случае ошибки + if (typeof onImageUrl === 'function') { + onImageUrl(""); // Передаем пустую строку при ошибке + } + + // Получаем label для отображения ошибки + const label = element.querySelector(".file-upload-label"); + if (label) { + // Разблокируем label + label.removeAttribute("disabled"); + // Отображаем сообщение об ошибке + label.textContent = "Ошибка загрузки"; + + // Возвращаем исходный текст через 2 секунды + setTimeout(() => { + // Повторно ищем label, так как DOM мог измениться + const currentLabel = element.querySelector(".file-upload-label"); + if (currentLabel) { + currentLabel.textContent = "Выберите фото"; + } + }, 2000); + } + } + } + }); + } + + // Получаем кнопку для удаления/замены изображения + const removeButton = element.querySelector(".file-upload-remove-button"); + if (removeButton) { + // Добавляем обработчик события click для кнопки + removeButton.addEventListener("click", () => { + // Очищаем URL изображения + imageUrl = ""; + // Вызываем callback с пустой строкой + if (typeof onImageUrl === 'function') { + onImageUrl(imageUrl); + } + // Перерендериваем компонент для отображения кнопки выбора фото + render(); }); + } }; - // Инициализация компонента + // Выполняем первый рендер компонента render(); } diff --git a/components/user-posts-page-component.js b/components/user-posts-page-component.js new file mode 100644 index 000000000..24e5a312e --- /dev/null +++ b/components/user-posts-page-component.js @@ -0,0 +1,105 @@ +import { renderHeaderComponent } from "./header-component.js"; +import { POSTS_PAGE } from "../routes.js"; +import { formatDistanceToNow } from "https://cdn.jsdelivr.net/npm/date-fns@2.29.3/esm/index.js"; +import { ru } from "https://cdn.jsdelivr.net/npm/date-fns@2.29.3/esm/locale/index.js"; + +export function renderUserPostsPageComponent({ appEl, posts, user, userId, goToPage, likePost, dislikePost }) { + const postsHtml = posts.map((post) => { + const createDate = new Date(post.createdAt); + const formattedDate = formatDistanceToNow(createDate, { addSuffix: true, locale: ru }); + + return ` +
  • +
    + +

    ${post.user.name}

    +
    +
    + Фото +
    +
    + +

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

    +
    +

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

    + +
  • + `; + }).join(''); + + const appHtml = ` +
    +
    +
    + +

    ${posts.length > 0 ? posts[0].user.name : 'Пользователь'}

    +
    +
      + ${postsHtml} +
    +
    `; + + appEl.innerHTML = appHtml; + + renderHeaderComponent({ + element: document.querySelector(".header-container"), + }); + + // Обработчик кнопки "Назад" + document.querySelector(".back-button").addEventListener("click", () => { + goToPage(POSTS_PAGE); + }); + + // Обработчики лайков + for (let likeEl of document.querySelectorAll(".like-button")) { + likeEl.addEventListener("click", (event) => { + event.stopPropagation(); + + if (!user) { + alert("Необходимо авторизоваться для установки лайков"); + goToPage(AUTH_PAGE); + return; + } + + const postId = likeEl.dataset.postId; + const post = posts.find(p => p.id === postId); + + if (post.isLiked) { + dislikePost({ token: `Bearer ${user.token}`, postId }) + .then((updatedPost) => { + const postIndex = posts.findIndex(p => p.id === postId); + if (postIndex !== -1) { + posts[postIndex] = updatedPost.post; + } + renderUserPostsPageComponent({ appEl, posts, user, userId, goToPage, likePost, dislikePost }); + }) + .catch((error) => { + console.error("Failed to dislike post:", error); + alert("Не удалось убрать лайк. Попробуйте позже."); + }); + } else { + likePost({ token: `Bearer ${user.token}`, postId }) + .then((updatedPost) => { + const postIndex = posts.findIndex(p => p.id === postId); + if (postIndex !== -1) { + posts[postIndex] = updatedPost.post; + } + renderUserPostsPageComponent({ appEl, posts, user, userId, goToPage, likePost, dislikePost }); + }) + .catch((error) => { + console.error("Failed to like post:", error); + alert("Не удалось поставить лайк. Попробуйте позже."); + }); + } + }); + } +} diff --git a/helpers.js b/helpers.js index b47d3b777..1c89960cf 100644 --- a/helpers.js +++ b/helpers.js @@ -1,15 +1,68 @@ +/** + * Сохраняет объект пользователя в localStorage. + * @param {Object|null} user - Объект пользователя для сохранения. + */ export function saveUserToLocalStorage(user) { - window.localStorage.setItem("user", JSON.stringify(user)); + // Проверка на null или undefined перед сериализацией. + if (user === null || user === undefined) { + console.warn("Helpers: Попытка сохранить пустого пользователя в localStorage."); + // Удаляем запись, если передан null/undefined + removeUserFromLocalStorage(); + return; + } + + try { + // Проверка, что user является объектом + if (typeof user === 'object' && user !== null && !Array.isArray(user)) { + // Проверяем наличие обязательных полей перед сохранением. + // id может быть 0, поэтому проверяем на !== undefined + if (user.token && user.name && user.id !== undefined) { + window.localStorage.setItem("user", JSON.stringify(user)); + console.log("Helpers: Пользователь успешно сохранен в localStorage."); + } else { + console.warn("Helpers: Объект пользователя не содержит всех обязательных полей (token, name, id).", user); + } + } else { + console.warn("Helpers: Попытка сохранить некорректный объект пользователя.", user); + } + } catch (e) { + console.error("Helpers: Ошибка сохранения пользователя в localStorage:", e); + } } -export function getUserFromLocalStorage(user) { +/** + * Получает объект пользователя из localStorage. + * @returns {Object|null} - Объект пользователя или null, если не найден или ошибка. + */ +export function getUserFromLocalStorage() { try { - return JSON.parse(window.localStorage.getItem("user")); + const userStr = window.localStorage.getItem("user"); + // Проверка, что строка не пустая и не "null"/"undefined" + if (userStr && userStr !== "null" && userStr !== "undefined") { + const user = JSON.parse(userStr); + // Проверка корректности десериализованного объекта + // id может быть 0, поэтому проверяем на !== undefined + if (user && typeof user === 'object' && user.token && user.name && user.id !== undefined) { + console.log("Helpers: Пользователь успешно получен из localStorage."); + return user; + } else { + console.warn("Helpers: Некорректные данные пользователя в localStorage.", user); + return null; + } + } + return null; } catch (error) { + console.error("Helpers: Ошибка получения пользователя из localStorage:", error); return null; } } -export function removeUserFromLocalStorage(user) { - window.localStorage.removeItem("user"); + +export function removeUserFromLocalStorage() { + try { + window.localStorage.removeItem("user"); + console.log("Helpers: Пользователь успешно удален из localStorage."); + } catch (e) { + console.error("Helpers: Ошибка удаления пользователя из localStorage:", e); + } } diff --git a/index.html b/index.html index 96674b4d3..499c75e10 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,9 @@ + + + diff --git a/index.js b/index.js index 7f1817c75..23f461f14 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,12 @@ -import { getPosts } from "./api.js"; +// index.js +import { + getPosts, + addPost, + getUserPosts, + likePost, + dislikePost, + uploadImage +} from "./api.js"; import { renderAddPostPageComponent } from "./components/add-post-page-component.js"; import { renderAuthPageComponent } from "./components/auth-page-component.js"; import { @@ -20,21 +28,48 @@ export let user = getUserFromLocalStorage(); export let page = null; export let posts = []; -const getToken = () => { + +export const getToken = () => { + const token = user ? `Bearer ${user.token}` : undefined; + console.log("Index: Полученный токен:", token); return token; }; + export const logout = () => { user = null; removeUserFromLocalStorage(); goToPage(POSTS_PAGE); }; -/** - * Включает страницу приложения - */ + +const smoothPageTransition = (callback) => { + const appEl = document.getElementById("app"); + const container = appEl.querySelector('.page-container'); + + if (container) { + container.classList.remove('active'); + setTimeout(() => { + callback(); + setTimeout(() => { + const newContainer = appEl.querySelector('.page-container'); + if (newContainer) { + newContainer.classList.add('page-transition'); + setTimeout(() => { + newContainer.classList.add('active'); + }, 10); + } + }, 10); + }, 300); + } else { + callback(); + } +}; + + export const goToPage = (newPage, data) => { + if ( [ POSTS_PAGE, @@ -44,8 +79,9 @@ export const goToPage = (newPage, data) => { LOADING_PAGE, ].includes(newPage) ) { + if (newPage === ADD_POSTS_PAGE) { - /* Если пользователь не авторизован, то отправляем его на страницу авторизации перед добавлением поста */ + page = user ? ADD_POSTS_PAGE : AUTH_PAGE; return renderApp(); } @@ -54,26 +90,45 @@ export const goToPage = (newPage, data) => { page = LOADING_PAGE; renderApp(); - return getPosts({ token: getToken() }) + return getPosts({ token: getToken() }) .then((newPosts) => { + console.log("Index: Получены посты:", newPosts); page = POSTS_PAGE; posts = newPosts; renderApp(); }) .catch((error) => { - console.error(error); - goToPage(POSTS_PAGE); + console.error("Index: Ошибка получения постов:", error); + + alert("Не удалось получить посты: " + error.message); + page = POSTS_PAGE; + renderApp(); }); } if (newPage === USER_POSTS_PAGE) { - // @@TODO: реализовать получение постов юзера из API - console.log("Открываю страницу пользователя: ", data.userId); - page = USER_POSTS_PAGE; - posts = []; - return renderApp(); + page = LOADING_PAGE; + renderApp(); + + + return getUserPosts({ token: getToken(), userId: data?.userId }) + .then((userPosts) => { + console.log("Index: Получены посты пользователя:", userPosts); + page = USER_POSTS_PAGE; + posts = userPosts; + renderApp(); + }) + .catch((error) => { + console.error("Index: Ошибка получения постов пользователя:", error); + + alert("Не удалось получить посты пользователя: " + error.message); + + page = POSTS_PAGE; + renderApp(); + }); } + page = newPage; renderApp(); @@ -83,8 +138,75 @@ export const goToPage = (newPage, data) => { throw new Error("страницы не существует"); }; + +export const toggleLike = (postId, isLiked) => { + const token = getToken(); + + + if (!token) { + console.log("Index: Попытка лайка без авторизации, перенаправляем на AUTH_PAGE"); + const appEl = document.getElementById("app"); + const container = appEl.querySelector('.page-container'); + + if (container) { + container.classList.remove('active'); + setTimeout(() => { + goToPage(AUTH_PAGE); + }, 300); + } else { + goToPage(AUTH_PAGE); + } + return Promise.resolve(); + } + + const likePromise = isLiked ? dislikePost : likePost; + + return likePromise({ token, postId }) + .then((responseData) => { + console.log("Index: Лайк/дизлайк успешен:", responseData); + const postIndex = posts.findIndex(post => post.id === postId); + if (postIndex !== -1) { + + posts[postIndex] = responseData.post; + + updatePostInDOM(postId, responseData.post); + } + }) + .catch((error) => { + console.error("Index: Ошибка при работе с лайком:", error); + alert("Не удалось выполнить действие: " + error.message); + }); +}; + + +function updatePostInDOM(postId, updatedPost) { + + const postElement = document.querySelector(`.like-button[data-post-id="${postId}"]`); + if (postElement) { + const likeImage = postElement.querySelector('img'); + + const newLikeImage = updatedPost.isLiked + ? "./assets/images/like-active.svg" + : "./assets/images/like-not-active.svg"; + if (likeImage) { + likeImage.src = newLikeImage; + } + + + const likesCountElement = postElement.closest('.post-likes')?.querySelector('.post-likes-text strong'); + if (likesCountElement) { + likesCountElement.textContent = updatedPost.likes.length; + } + + + postElement.dataset.isLiked = updatedPost.isLiked; + } +} + + const renderApp = () => { const appEl = document.getElementById("app"); + if (page === LOADING_PAGE) { return renderLoadingPageComponent({ appEl, @@ -97,9 +219,11 @@ const renderApp = () => { return renderAuthPageComponent({ appEl, setUser: (newUser) => { + console.log("Index: Установка нового пользователя:", newUser); user = newUser; saveUserToLocalStorage(user); - goToPage(POSTS_PAGE); + + smoothPageTransition(() => goToPage(POSTS_PAGE)); }, user, goToPage, @@ -107,12 +231,48 @@ const renderApp = () => { } if (page === ADD_POSTS_PAGE) { + + if (!user) { + console.log("Index: Попытка доступа к ADD_POSTS_PAGE без авторизации"); + goToPage(AUTH_PAGE); + return; + } + return renderAddPostPageComponent({ appEl, - onAddPostClick({ description, imageUrl }) { - // @TODO: реализовать добавление поста в API - console.log("Добавляю пост...", { description, imageUrl }); - goToPage(POSTS_PAGE); + + onAddPostClick: ({ description, imageUrl }) => { + const token = getToken(); + console.log("Index: onAddPostClick вызван с данными:", { description, imageUrl }); + + if (!token) { + console.log("Index: Нет токена при попытке добавить пост"); + + smoothPageTransition(() => goToPage(AUTH_PAGE)); + return; + } + + if (!description.trim()) { + alert("Введите описание поста"); + return; + } + + if (!imageUrl) { + alert("Загрузите изображение"); + return; + } + + + addPost({ token, description, imageUrl }) + .then((result) => { + console.log("Index: Пост успешно добавлен:", result); + + smoothPageTransition(() => goToPage(POSTS_PAGE)); + }) + .catch((error) => { + console.error("Index: Ошибка при добавлении поста:", error); + alert("Не удалось добавить пост: " + error.message); + }); }, }); } @@ -120,13 +280,22 @@ const renderApp = () => { if (page === POSTS_PAGE) { return renderPostsPageComponent({ appEl, + posts, + user, + goToPage, + toggleLike, }); } if (page === USER_POSTS_PAGE) { - // @TODO: реализовать страницу с фотографиями отдельного пользвателя - appEl.innerHTML = "Здесь будет страница фотографий пользователя"; - return; + return renderPostsPageComponent({ + appEl, + posts, + user, + goToPage, + toggleLike, + isUserPostsPage: true, + }); } }; diff --git a/routes.js b/routes.js index 725f8eb1a..53d809a3e 100644 --- a/routes.js +++ b/routes.js @@ -1,6 +1,6 @@ -/* Файл со списком страниц приложения */ -export const POSTS_PAGE = "posts"; -export const USER_POSTS_PAGE = "user-posts"; -export const AUTH_PAGE = "auth"; -export const ADD_POSTS_PAGE = "add-post"; -export const LOADING_PAGE = "loading"; +// routes.js +export const AUTH_PAGE = "AUTH_PAGE"; +export const POSTS_PAGE = "POSTS_PAGE"; +export const ADD_POSTS_PAGE = "ADD_POSTS_PAGE"; +export const USER_POSTS_PAGE = "USER_POSTS_PAGE"; +export const LOADING_PAGE = "LOADING_PAGE"; diff --git a/styles.css b/styles.css index 57bc08b16..cf17298c5 100644 --- a/styles.css +++ b/styles.css @@ -91,6 +91,11 @@ padding-left: 0px; padding-bottom: 5px; cursor: pointer; + transition: transform 0.2s ease; +} + +.like-button:active { + transform: scale(1.2); } .user-name { @@ -196,3 +201,132 @@ width: 100%; text-align: center; } + + + + +.page-container { + opacity: 1; + transition: opacity 0.3s ease; +} + + +.loading-spinner { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +.spinner { + width: 50px; + height: 50px; + border: 5px solid #f3f3f3; + border-top: 5px solid #0095f6; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + + +.post { + animation: fadeIn 0.5s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + + +.like-button.liked { + animation: pulse 0.3s ease; +} + +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.3); } + 100% { transform: scale(1); } +} + + +.form-error { + padding: 10px; + border-radius: 4px; + background-color: #ffebee; + border-left: 4px solid #f44336; + animation: shake 0.5s ease; + margin-top: 10px; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } +} + + +.button { + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); +} + +.button:active { + transform: translateY(0); +} + + +.page-transition { + opacity: 0; + transform: translateY(20px); + transition: all 0.3s ease; +} + +.page-transition.active { + opacity: 1; + transform: translateY(0); +} + + +.form-textarea { + width: 100%; + min-height: 100px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; + resize: vertical; +} + +.button { + background-color: #0095f6; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-weight: 500; +} + +.button:disabled { + background-color: #b2dffc; + cursor: not-allowed; +} + +.upload-image-container { + margin-bottom: 15px; +} + + +.post-likes-text strong { + transition: all 0.2s ease; +}