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 = `
-
-
-
-
`;
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
-
-
-
- Иван Иваныч
- Ромашка, ромашка...
-
-
- 19 минут назад
-
-
- -
-
-
-
-
-

-
-
-
-
- Нравится: 35
-
-
-
- Варварва Н.
- Нарисовала картину, посмотрите какая красивая
-
-
- 3 часа назад
-
-
- -
-
-
-
-
-

-
-
-
-
- Нравится: 0
-
-
-
- Варварва Н.
- Голова
-
-
- 8 дней назад
-
-
-
-
`;
-
- 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 `
+
+
+
+

+
+
+
+
+ Нравится: ${post.likes.length}
+
+
+
+ ${post.user.name}
+ ${escapeHTML(post.description)}
+
+ ${createdAt}
+
+ ${user && post.user.login === user.login ? `
+
+ ` : ""}
+ `;
+ })
+ .join("");
+
+ const appHtml = `
+
`;
- 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
-
+
-
+
-
-
-