diff --git a/README.md b/README.md index 34719e139..6d3a0294f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,78 @@ -# Instapro +# Instapro — социальная сеть SkyPro -MVP социальной сети для обмена фотографиями +Это клиентская часть приложения, написанная на чистом JavaScript. +Приложение позволяет: +- Регистрироваться и авторизовываться +- Просматривать ленту постов (общая и пользовательские) +- Ставить и убирать лайки +- Добавлять собственные посты с описанием и изображением +- Удалять свои посты с подтверждением через всплывающее окно -## Первоначальная оценка +--- -ХХХХ часов +## 📌 Основные реализованные фичи -## Фактически затраченное время +✅ **Авторизация и регистрация** +✅ **Лента постов (общая и пользовательская)** +✅ **Добавление постов с загрузкой изображения** +✅ **Лайки/дизлайки с анимацией при наведении и клике** +✅ **Удаление поста с подтверждением через `confirm()`** +✅ **Адаптивный интерфейс** +✅ **Экранирование HTML-тегов в описании поста** -YYYY часов +--- + +## ⏱️ Затрачённое время + +🕒 **Первоначальная оценка:** 8 часов +🕒 **Фактически затрачено:** ~12 часов + +### Причины перерасхода времени: +- Разбор ошибок API (`400`, `401`) +- Исправление запросов без `Content-Type` +- Разбор ошибок с регистрацией и авторизацией +- Реализация анимаций и улучшений UX +- Устранение проблем с маршрутизацией и отрисовкой страниц + +--- + +## 💡 Дополнительные улучшения + +- 🎨 **Современный внешний вид:** добавлены отступы, стилизованные кнопки, тени, скругления +- 🖼 **Анимации лайков:** плавное увеличение при наведении и пульсация при клике +- 🔐 **Экранирование HTML в описании поста:** защита от XSS +- 📱 **Адаптивность:** корректное отображение на мобильных устройствах +- ❌ **Удаление поста с подтверждением** +- 👤 **Переход к постам пользователя по клику на имя** + +--- + +## 🔧 Технические детали + +- **API:** https://wedev-api.sky.pro +- **Авторизация:** Bearer токен +- **Формат данных:** JSON +- **Загрузка изображений:** через `FormData` +- **Клиентская маршрутизация:** реализована вручную через `goToPage(...)` и `renderApp()` + +--- + +## 🛠 Как запустить проект: + +1. Клонируйте репозиторий +2. Откройте `index.html` через Live Server или любой локальный сервер +3. Перейдите на главную страницу → всё работает! + +--- + +## 📌 Вывод + +Проект полностью соответствует техническому заданию и успешно прошёл все этапы разработки. + +Были выполнены следующие доработки: +- Улучшен внешний вид и адаптивность +- Добавано экранирование HTML в постах +- Убраны все `console.log(...)` +- Оставлены только обработчики ошибок через `console.error(...)` + +Проект готов к сдаче! \ No newline at end of file diff --git a/api.js b/api.js index 123a9e3b6..403eebcb4 100644 --- a/api.js +++ b/api.js @@ -1,21 +1,50 @@ -// Замени на свой, чтобы получить независимый от других набор данных. -// "боевая" версия инстапро лежит в ключе prod 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`; export function getPosts({ token }) { + const headers = {}; + if (token && token !== "Bearer undefined") { + headers.Authorization = token; + } return fetch(postsHost, { method: "GET", - headers: { - Authorization: token, - }, + headers, }) .then((response) => { if (response.status === 401) { throw new Error("Нет авторизации"); } + if (!response.ok) { + return response.text().then((text) => { + throw new Error(`Ошибка сервера: ${response.status} ${text}`); + }); + } + return response.json(); + }) + .then((data) => { + return data.posts; + }); +} +export function getUserPosts({ token, userId }) { + const headers = {}; + if (token && token !== "Bearer undefined") { + headers.Authorization = token; + } + return fetch(`${postsHost}/user-posts/${userId}`, { + method: "GET", + headers, + }) + .then((response) => { + if (response.status === 401) { + throw new Error("Нет авторизации"); + } + if (!response.ok) { + return response.text().then((text) => { + throw new Error(`Ошибка сервера: ${response.status} ${text}`); + }); + } return response.json(); }) .then((data) => { @@ -23,18 +52,89 @@ export function getPosts({ token }) { }); } -export function registerUser({ login, password, name, imageUrl }) { - return fetch(baseHost + "/api/user", { +export function addPost({ token, description, imageUrl }) { + + if (!description || !imageUrl) { + throw new Error("Описание или URL изображения не переданы"); + } + + return fetch(postsHost, { method: "POST", + headers: { + Authorization: token, + }, body: JSON.stringify({ - login, - password, - name, + description, imageUrl, }), }).then((response) => { if (response.status === 400) { - throw new Error("Такой пользователь уже существует"); + return response.json().then((data) => { + throw new Error(data.error || "Некорректные данные поста"); + }); + } + if (response.status === 401) { + throw new Error("Нет авторизации"); + } + if (!response.ok) { + throw new Error(`Ошибка сервера: ${response.status}`); + } + return response.json(); + }); +} + +export function likePost({ token, postId }) { + return fetch(`${postsHost}/${postId}/like`, { + method: "POST", + headers: { + Authorization: token, + }, + }) + .then((response) => { + if (response.status === 401) { + throw new Error("Нет авторизации"); + } + return response.json(); + }); +} + +export function dislikePost({ token, postId }) { + return fetch(`${postsHost}/${postId}/dislike`, { + method: "POST", + headers: { + Authorization: token, + }, + }) + .then((response) => { + if (response.status === 401) { + throw new Error("Нет авторизации"); + } + return response.json(); + }); +} + +export function registerUser({ login, password, name, imageUrl }) { + const body = { + login, + password, + name, + }; + + if (imageUrl) { + body.imageUrl = imageUrl; + } + + return fetch("https://wedev-api.sky.pro/api/user", { + method: "POST", + body: JSON.stringify(body), + }).then((response) => { + if (response.status === 400) { + return response.json().then((data) => { + throw new Error(data.error || "Ошибка регистрации"); + }); + } + if (!response.ok) { + throw new Error(`Ошибка сервера: ${response.status}`); } return response.json(); }); @@ -49,13 +149,17 @@ export function loginUser({ login, password }) { }), }).then((response) => { if (response.status === 400) { - throw new Error("Неверный логин или пароль"); + return response.json().then((data) => { + throw new Error(data.error || "Неверный логин или пароль"); + }); + } + if (!response.ok) { + throw new Error(`Ошибка сервера: ${response.status}`); } return response.json(); }); } -// Загружает картинку в облако, возвращает url загруженной картинки export function uploadImage({ file }) { const data = new FormData(); data.append("file", file); @@ -64,6 +168,25 @@ export function uploadImage({ file }) { method: "POST", body: data, }).then((response) => { + if (response.status !== 200) { + throw new Error("Ошибка загрузки изображения"); + } return response.json(); }); } + +export function verifyToken({ token }) { + return fetch(baseHost + "/api/user/me", { + method: "GET", + headers: { + Authorization: token, + }, + }) + .then((response) => { + return response.status === 200; + }) + .catch((error) => { + console.error("verifyToken error:", error); + return false; + }); +} diff --git a/components/add-post-page-component.js b/components/add-post-page-component.js index 8277f093b..063f3b29f 100644 --- a/components/add-post-page-component.js +++ b/components/add-post-page-component.js @@ -1,23 +1,65 @@ +import { renderHeaderComponent } from "./header-component.js"; +import { renderUploadImageComponent } from "./upload-image-component.js"; +import { addPost } from "../api.js"; +import { goToPage, user, posts } from "../index.js"; +import { POSTS_PAGE } from "../routes.js"; + export function renderAddPostPageComponent({ appEl, onAddPostClick }) { + let imageUrl = ""; + const render = () => { - // @TODO: Реализовать страницу добавления поста const appHtml = ` -
-
- Cтраница добавления поста - -
- `; +
+
+
+

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

+
+
+ + +
+
+
`; appEl.innerHTML = appHtml; - document.getElementById("add-button").addEventListener("click", () => { - onAddPostClick({ - description: "Описание картинки", - imageUrl: "https://image.png", - }); + renderHeaderComponent({ + element: document.querySelector(".header-container"), + }); + + const uploadImageContainer = appEl.querySelector(".upload-image-container"); + renderUploadImageComponent({ + element: uploadImageContainer, + onImageUrlChange(newImageUrl) { + imageUrl = newImageUrl; + }, + }); + +document.getElementById("add-button").addEventListener("click", () => { + const description = document.getElementById("description-input").value.trim(); + + if (!description) { + alert("Введите описание поста"); + return; + } + if (!imageUrl) { + alert("Выберите изображение"); + return; + } + + const token = user && user.token ? `Bearer ${user.token}` : undefined; + + + addPost({ token, description, imageUrl }) + .then(() => { + goToPage(POSTS_PAGE); + }) + .catch((error) => { + console.error("Ошибка при добавлении поста:", error); + alert("Ошибка при добавлении поста. Попробуйте снова."); }); +}); }; render(); -} +} \ No newline at end of file diff --git a/components/auth-page-component.js b/components/auth-page-component.js index 0e93530ec..fbd466f9b 100644 --- a/components/auth-page-component.js +++ b/components/auth-page-component.js @@ -1,172 +1,174 @@ -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 - Функция, вызываемая при успешной авторизации или регистрации. - * Принимает объект пользователя в качестве аргумента. - */ -export function renderAuthPageComponent({ appEl, setUser }) { - /** - * Флаг, указывающий текущий режим формы. - * Если `true`, форма находится в режиме входа. Если `false`, в режиме регистрации. - * @type {boolean} - */ +import { loginUser, registerUser, uploadImage } from "../api.js"; + +export function renderAuthPageComponent({ appEl, setUser, user, goToPage }) { let isLoginMode = true; + let imageUrl = ""; // будет заполнено при загрузке изображения - /** - * URL изображения, загруженного пользователем при регистрации. - * Используется только в режиме регистрации. - * @type {string} - */ - let imageUrl = ""; - - /** - * Рендерит форму авторизации или регистрации. - * В зависимости от значения `isLoginMode` отображает соответствующий интерфейс. - */ const renderForm = () => { const appHtml = `
-
-
-

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

-
- ${ - !isLoginMode - ? ` -
- - ` - : "" - } - - -
- +
+
+

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

+
+ ${!isLoginMode ? ` +
+ +
- +
+
-
- `; +
+
`; appEl.innerHTML = appHtml; - /** - * Устанавливает сообщение об ошибке в форме. - * @param {string} message - Текст сообщения об ошибке. - */ - const setError = (message) => { - appEl.querySelector(".form-error").textContent = message; - }; - - // Рендерим заголовок страницы renderHeaderComponent({ element: document.querySelector(".header-container"), + user, + goToPage, }); - // Если режим регистрации, рендерим компонент загрузки изображения - const uploadImageContainer = appEl.querySelector(".upload-image-container"); - if (uploadImageContainer) { - renderUploadImageComponent({ - element: uploadImageContainer, - onImageUrlChange(newImageUrl) { - imageUrl = newImageUrl; - }, - }); - } + const loginInputElement = document.getElementById("login-input"); + const passwordInputElement = document.getElementById("password-input"); + const nameInputElement = document.getElementById("name-input"); + const imageInputElement = document.getElementById("image-input"); + const registerButtonElement = document.getElementById("register-button"); + const loginButtonElement = document.getElementById("login-button"); + const toggleButtonElement = document.getElementById("toggle-button"); + const authErrorMessageElement = document.querySelector(".form-error"); - // Обработка клика на кнопку входа/регистрации - document.getElementById("login-button").addEventListener("click", () => { - setError(""); + const setError = (message) => { + if (authErrorMessageElement) { + authErrorMessageElement.textContent = message; + } + }; + + toggleButtonElement.addEventListener("click", () => { + isLoginMode = !isLoginMode; + imageUrl = ""; + setError(""); // Сбрасываем ошибку при смене формы + renderForm(); + }); - if (isLoginMode) { - // Обработка входа - const login = document.getElementById("login-input").value; - const password = document.getElementById("password-input").value; + if (loginButtonElement) { + loginButtonElement.addEventListener("click", () => { + setError(""); + + const login = loginInputElement?.value?.trim() || ""; + const password = passwordInputElement?.value?.trim() || ""; if (!login) { - alert("Введите логин"); + setError("Введите логин"); return; } if (!password) { - alert("Введите пароль"); + setError("Введите пароль"); return; } loginUser({ login, password }) - .then((user) => { - setUser(user.user); + .then((userData) => { + if (!userData.user?.token) { + throw new Error("Токен не получен"); + } + setUser(userData.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 (registerButtonElement) { + registerButtonElement.addEventListener("click", () => { + setError(""); + + const login = loginInputElement?.value?.trim() || ""; + const password = passwordInputElement?.value?.trim() || ""; + const name = nameInputElement?.value?.trim() || ""; if (!login) { - alert("Введите логин"); + setError("Введите логин"); return; } if (!password) { - alert("Введите пароль"); + setError("Введите пароль"); return; } - if (!imageUrl) { - alert("Не выбрана фотография"); + if (!name) { + setError("Введите имя"); return; } - registerUser({ login, password, name, imageUrl }) - .then((user) => { - setUser(user.user); + const data = { + login, + password, + name, + }; + + if (imageUrl) { + data.imageUrl = imageUrl; + } + + registerUser(data) + .then((userData) => { + if (!userData.user?.token) { + throw new Error("Токен не получен"); + } + setUser(userData.user); }) .catch((error) => { - console.warn(error); setError(error.message); }); - } - }); + }); + } - // Обработка переключения режима (вход ↔ регистрация) - document.getElementById("toggle-button").addEventListener("click", () => { - isLoginMode = !isLoginMode; - renderForm(); // Перерисовываем форму с новым режимом - }); + if (imageInputElement) { + imageInputElement.addEventListener("change", (event) => { + const file = event.target.files[0]; + if (file) { + imageInputElement.disabled = true; + setError("Загрузка изображения..."); + uploadImage({ file }) + .then((data) => { + imageUrl = data.fileUrl; + setError(""); + imageInputElement.disabled = false; + }) + .catch(() => { + setError("Ошибка загрузки изображения"); + imageInputElement.disabled = false; + }); + } + }); + } }; - // Инициализация формы renderForm(); -} +} \ No newline at end of file diff --git a/components/posts-page-component.js b/components/posts-page-component.js index 662ccdbdc..281b395a9 100644 --- a/components/posts-page-component.js +++ b/components/posts-page-component.js @@ -1,109 +1,134 @@ import { USER_POSTS_PAGE } from "../routes.js"; import { renderHeaderComponent } from "./header-component.js"; -import { posts, goToPage } from "../index.js"; +import { posts, goToPage, user } from "../index.js"; +import { likePost, dislikePost } from "../api.js"; + +function escapeHTML(str) { + return str.replace(/[&<>'"]/g, (match) => ({ + "&": "&", + "<": "<", + ">": ">", + "'": "'", + '"': """, + }[match])); +} export function renderPostsPageComponent({ appEl }) { - // @TODO: реализовать рендер постов из api - console.log("Актуальный список постов:", posts); - - /** - * @TODO: чтобы отформатировать дату создания поста в виде "19 минут назад" - * можно использовать https://date-fns.org/v2.29.3/docs/formatDistanceToNow - */ - const appHtml = ` -
-
-
    -
  • -
    - -

    Иван Иваныч

    -
    -
    - -
    -
    - -

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

    -
    -

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

    - -
  • -
  • -
    - -

    Варварва Н.

    -
    - - -
    - -
    -
    - -

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

    -
    -

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

    - -
  • -
  • -
    - -

    Варварва Н.

    -
    - - -
    - -
    -
    - -

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

    -
    -

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

    - -
  • -
-
`; - - appEl.innerHTML = appHtml; - - renderHeaderComponent({ - element: document.querySelector(".header-container"), - }); + const renderPosts = () => { + const postsHtml = posts + .map((post) => { + // Упрощённый вывод даты + const date = new Date(post.createdAt); + const createdAt = `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; + + return ` +
  • +
    + User avatar +

    ${post.user.name}

    +
    +
    + Post image +
    +
    + +

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

    +
    +

    + ${post.user.name} + ${escapeHTML(post.description)} +

    + + + ${user && post.user.login === user.login ? ` + + ` : ""} +
  • `; + }) + .join(""); + + const appHtml = ` +
    +
    +
      ${postsHtml}
    +
    `; - for (let userEl of document.querySelectorAll(".post-header")) { - userEl.addEventListener("click", () => { - goToPage(USER_POSTS_PAGE, { - userId: userEl.dataset.userId, + appEl.innerHTML = appHtml; + + renderHeaderComponent({ + element: document.querySelector(".header-container"), + user, + goToPage, + }); + + // Переход к пользователю + for (let userEl of document.querySelectorAll(".post-header")) { + userEl.addEventListener("click", () => { + const userId = userEl.dataset.userId; + goToPage(USER_POSTS_PAGE, { userId }); }); + } + + // Лайки + for (let likeButton of document.querySelectorAll(".like-button")) { + likeButton.addEventListener("click", () => { + const postId = likeButton.dataset.postId; + const post = posts.find((p) => p.id === postId); + const token = user ? `Bearer ${user.token}` : undefined; + + const action = post.isLiked ? dislikePost : likePost; + + action({ token, postId }).then(({ post: updatedPost }) => { + const index = posts.findIndex((p) => p.id === postId); + posts[index] = updatedPost; + + // Получаем элемент из DOM + const buttonEl = document.querySelector(`[data-post-id="${postId}"]`); + const imageEl = buttonEl?.querySelector("img"); + + if (imageEl) { + // Меняем изображение на active/not-active + imageEl.src = "./assets/images/like-" + (updatedPost.isLiked ? "active" : "not-active") + ".svg"; + + // Запускаем анимацию пульсации + imageEl.classList.add("liked"); + setTimeout(() => imageEl.classList.remove("liked"), 300); + } + + renderPosts(); // Перерисовываем посты для обновления количества лайков }); - } + }); } + + // Удаление поста + for (let deleteButton of document.querySelectorAll(".delete-button")) { + deleteButton.addEventListener("click", (event) => { + const postId = deleteButton.dataset.postId; + + if (!confirm("Вы точно хотите удалить пост?")) return; + + fetch(`https://wedev-api.sky.pro/api/v1/prod/instapro/ ${postId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${user.token}`, + }, + }) + .then(() => { + const index = posts.findIndex((p) => p.id === postId); + posts.splice(index, 1); + renderPosts(); + }) + .catch((error) => { + console.error("Ошибка удаления поста:", error); + alert("Не удалось удалить пост"); + }); + }); + } + }; + + renderPosts(); +} \ No newline at end of file diff --git a/helpers.js b/helpers.js index b47d3b777..781aadf85 100644 --- a/helpers.js +++ b/helpers.js @@ -1,15 +1,21 @@ export function saveUserToLocalStorage(user) { - window.localStorage.setItem("user", JSON.stringify(user)); + try { + localStorage.setItem("user", JSON.stringify(user)); + } catch (error) { + console.error("Error saving user to localStorage:", error); + } } -export function getUserFromLocalStorage(user) { +export function getUserFromLocalStorage() { try { - return JSON.parse(window.localStorage.getItem("user")); + const user = localStorage.getItem("user"); + return user ? JSON.parse(user) : null; } catch (error) { + console.error("Error retrieving user from localStorage:", error); return null; } } -export function removeUserFromLocalStorage(user) { - window.localStorage.removeItem("user"); -} +export function removeUserFromLocalStorage() { + localStorage.removeItem("user"); +} \ No newline at end of file diff --git a/index.html b/index.html index 96674b4d3..6384d083d 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,12 @@ Instapro - + -
    + - - - + \ No newline at end of file diff --git a/index.js b/index.js index 7f1817c75..ad63b35f2 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ -import { getPosts } from "./api.js"; +import { getPosts, getUserPosts, } from "./api.js"; import { renderAddPostPageComponent } from "./components/add-post-page-component.js"; import { renderAuthPageComponent } from "./components/auth-page-component.js"; import { @@ -21,7 +21,7 @@ export let page = null; export let posts = []; const getToken = () => { - const token = user ? `Bearer ${user.token}` : undefined; + const token = user && user.token ? `Bearer ${user.token}` : undefined; return token; }; @@ -31,9 +31,7 @@ export const logout = () => { goToPage(POSTS_PAGE); }; -/** - * Включает страницу приложения - */ + export const goToPage = (newPage, data) => { if ( [ @@ -45,46 +43,72 @@ export const goToPage = (newPage, data) => { ].includes(newPage) ) { if (newPage === ADD_POSTS_PAGE) { - /* Если пользователь не авторизован, то отправляем его на страницу авторизации перед добавлением поста */ page = user ? ADD_POSTS_PAGE : AUTH_PAGE; - return renderApp(); + renderApp(); + return; } if (newPage === POSTS_PAGE) { page = LOADING_PAGE; renderApp(); - return getPosts({ token: getToken() }) + getPosts({ token: getToken() }) .then((newPosts) => { page = POSTS_PAGE; posts = newPosts; renderApp(); }) .catch((error) => { - console.error(error); - goToPage(POSTS_PAGE); + console.error("Error fetching posts:", error); + page = POSTS_PAGE; + posts = []; + renderApp(); + const appEl = document.getElementById("app"); + const errorEl = document.createElement("div"); + errorEl.className = "error-message"; + errorEl.textContent = `Не удалось загрузить посты: ${error.message}. Попробуйте позже.`; + appEl.appendChild(errorEl); }); + return; } if (newPage === USER_POSTS_PAGE) { - // @@TODO: реализовать получение постов юзера из API - console.log("Открываю страницу пользователя: ", data.userId); - page = USER_POSTS_PAGE; - posts = []; - return renderApp(); + page = LOADING_PAGE; + renderApp(); + + getUserPosts({ token: getToken(), userId: data.userId }) + .then((newPosts) => { + page = USER_POSTS_PAGE; + posts = newPosts; + renderApp(); + }) + .catch((error) => { + console.error("Error fetching user posts:", error); + page = POSTS_PAGE; + posts = []; + renderApp(); + const appEl = document.getElementById("app"); + const errorEl = document.createElement("div"); + errorEl.className = "error-message"; + errorEl.textContent = `Не удалось загрузить посты пользователя: ${error.message}. Попробуйте позже.`; + appEl.appendChild(errorEl); + }); + return; } page = newPage; renderApp(); - return; } - throw new Error("страницы не существует"); + console.error("Unknown page:", newPage); + const appEl = document.getElementById("app"); + appEl.innerHTML = `
    Ошибка: неизвестная страница
    `; }; const renderApp = () => { const appEl = document.getElementById("app"); + if (page === LOADING_PAGE) { return renderLoadingPageComponent({ appEl, @@ -110,24 +134,18 @@ const renderApp = () => { return renderAddPostPageComponent({ appEl, onAddPostClick({ description, imageUrl }) { - // @TODO: реализовать добавление поста в API - console.log("Добавляю пост...", { description, imageUrl }); goToPage(POSTS_PAGE); }, }); } - if (page === POSTS_PAGE) { + if (page === POSTS_PAGE || page === USER_POSTS_PAGE) { return renderPostsPageComponent({ appEl, }); } - if (page === USER_POSTS_PAGE) { - // @TODO: реализовать страницу с фотографиями отдельного пользвателя - appEl.innerHTML = "Здесь будет страница фотографий пользователя"; - return; - } + appEl.innerHTML = `
    Ошибка: неизвестная страница
    `; }; -goToPage(POSTS_PAGE); +goToPage(POSTS_PAGE); \ No newline at end of file diff --git a/styles.css b/styles.css index 57bc08b16..1d47c514f 100644 --- a/styles.css +++ b/styles.css @@ -1,7 +1,26 @@ +/* === Общие стили === */ + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", sans-serif; + background-color: #fafafa; + color: #222; +} + +/* === Контейнер страницы === */ + .page-container { + max-width: 600px; + margin: 0 auto; padding: 0 20px; } +/* === Шапка === */ + .page-header { display: flex; justify-content: space-between; @@ -10,6 +29,7 @@ padding: 20px 16px; margin-left: -20px; margin-right: -20px; + background-color: #fff; } .logo { @@ -19,10 +39,6 @@ width: 130px; } -.header-container { - margin-bottom: 8px; -} - .header-button { padding: 0; border: none; @@ -31,6 +47,7 @@ line-height: 18px; font-weight: 500; cursor: pointer; + color: #000; } .logout-button { @@ -38,19 +55,90 @@ text-align: right; } +/* === Форма регистрации/авторизации === */ + +.form { + display: flex; + flex-direction: column; + gap: 16px; + background-color: #fff; + padding: 20px; + border-radius: 8px; + margin-top: 20px; +} + +.form-title { + font-size: 28px; + line-height: 35px; + text-align: center; + margin-bottom: 20px; +} + +.form-inputs { + display: flex; + flex-direction: column; + gap: 10px; +} + +.input { + padding: 10px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 16px; +} + +.button { + padding: 12px; + background-color: #000; + color: #fff; + border: none; + border-radius: 6px; + font-size: 16px; + cursor: pointer; + margin-top: 8px; +} + +.button--link { + background-color: transparent; + color: #000; + font-size: 14px; + margin-top: 0; +} + +.form-buttons { + margin-top: 16px; + text-align: center; +} + +.form-error { + color: red; + font-size: 14px; + text-align: center; +} + +/* === Посты === */ + .posts { display: flex; flex-direction: column; list-style: none; padding: 0; - margin: 0; + margin-top: 20px; + gap: 20px; +} + +.post { + background-color: #fff; + border-radius: 8px; + padding: 16px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); } .post-header { display: flex; - flex-direction: row; align-items: center; - margin-bottom: 10px; + gap: 12px; + margin-bottom: 16px; cursor: pointer; } @@ -58,127 +146,130 @@ width: 40px; height: 40px; object-fit: cover; - border-radius: 40px; - margin-right: 10px; + border-radius: 50%; +} + +.post-header__user-name { + font-weight: bold; + font-size: 16px; } .post-image-container { - margin-left: -20px; - margin-right: -20px; - height: 500px; - display: flex; - justify-content: center; - background-color: #e0e0e0; + position: relative; + overflow: hidden; + border-radius: 8px; + margin-bottom: 16px; } .post-image { width: 100%; - height: 100%; - max-width: 500px; - object-fit: cover; + height: auto; + display: block; } .post-likes { display: flex; - flex-direction: row; align-items: center; + gap: 8px; + margin-bottom: 8px; } .like-button { + background: transparent; border: none; - background-color: transparent; - padding: 8px; - padding-left: 0px; - padding-bottom: 5px; + padding: 0; cursor: pointer; } -.user-name { - font-weight: 500; +.like-button img { + transition: transform 0.2s ease, filter 0.2s ease; + width: 24px; + height: 24px; } -.post-text { - font-size: 14px; - line-height: 18px; - margin-top: 5px; +.like-button:hover img { + transform: scale(1.2); + filter: brightness(0.9) saturate(1.2); } -.post-date { - color: #8a8a8a; +.like-button img.liked { + animation: pulse 0.3s ease; + filter: none; } -.post + .post { - margin-top: 20px; +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } } -.posts-user-header { - display: flex; - flex-direction: row; - align-items: center; - margin-bottom: 12px; +.post-likes-text { + font-size: 14px; + margin: 0; } -.posts-user-header__user-image { - width: 70px; - height: 70px; - object-fit: cover; - border-radius: 40px; - margin-right: 10px; +.post-text { + font-size: 16px; + line-height: 1.4; + margin: 10px 0; } -.posts-user-header__user-name { - font-size: 28px; - line-height: 35px; +.user-name { + font-weight: 500; + margin-right: 8px; } -.loading-page { - display: flex; - justify-content: center; - margin-top: 100px; +.post-date { + font-size: 12px; + color: #888; + margin-top: 8px; } +.delete-button { + margin-top: 12px; + background: transparent; + color: red; + border: none; + font-size: 14px; + cursor: pointer; +} + +/* === Загрузка поста === */ + .add-post-sign { background-image: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 122.88 122.88'%3E%3Ctitle%3Eadd%3C/title%3E%3Cpath d='M61.44,0A61.46,61.46,0,1,1,18,18,61.25,61.25,0,0,1,61.44,0ZM88.6,56.82v9.24a4,4,0,0,1-4,4H70V84.62a4,4,0,0,1-4,4H56.82a4,4,0,0,1-4-4V70H38.26a4,4,0,0,1-4-4V56.82a4,4,0,0,1,4-4H52.84V38.26a4,4,0,0,1,4-4h9.24a4,4,0,0,1,4,4V52.84H84.62a4,4,0,0,1,4,4Zm8.83-31.37a50.92,50.92,0,1,0,14.9,36,50.78,50.78,0,0,0-14.9-36Z'/%3E%3C/svg%3E"); height: 30px; width: 30px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; } -.form { - display: flex; - flex-direction: column; - width: 100%; -} +/* === Форма добавления поста === */ -.form-title { - font-size: 28px; - line-height: 35px; - text-align: center; +.upload-image-container { + margin-bottom: 16px; } -.form-inputs { - display: flex; - flex-direction: column; - gap: 10px; -} - -.form-error { - color: red; -} - -.form-footer { - margin-top: 50px; - display: flex; - flex-direction: column; - gap: 10px; +.file-upload-label { + display: inline-block; + width: 100%; + padding: 10px; + text-align: center; + border: 1px dashed #ccc; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; } -.form-footer-title { - text-align: center; +.file-upload-label:hover { + background-color: #f5f5f5; } -.file-upload-image-conrainer { +.file-upload-image-container { display: flex; align-items: center; + flex-wrap: wrap; } .file-upload-image { @@ -186,13 +277,59 @@ height: 100px; object-fit: cover; margin-right: 10px; - border: 1px solid gray; border-radius: 5px; + border: 1px solid #ddd; } -.file-upload-label { - display: inline-block; - box-sizing: border-box; - width: 100%; +.textarea { + resize: vertical; + min-height: 100px; + padding: 12px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 6px; + margin-bottom: 16px; +} + +/* === Ошибки и уведомления === */ + +.error-message { + background-color: #ffe6e6; + color: #d32f2f; + padding: 10px; + margin: 10px 0; + border: 1px solid #d32f2f; + border-radius: 4px; text-align: center; } + +/* === Адаптивность (мобильные) === */ + +@media (max-width: 600px) { + .page-container { + padding: 0 10px; + } + + .post-header { + flex-direction: row; + align-items: center; + } + + .post-header__user-name { + font-size: 16px; + } + + .post-image { + width: 100%; + height: auto; + } + + .like-button img { + width: 20px; + height: 20px; + } + + .delete-button { + font-size: 14px; + } +} \ No newline at end of file