diff --git a/api.js b/api.js index 123a9e3b6..8908b3491 100644 --- a/api.js +++ b/api.js @@ -1,5 +1,5 @@ -// Замени на свой, чтобы получить независимый от других набор данных. -// "боевая" версия инстапро лежит в ключе prod +// api.js - ПОЛНОСТЬЮ ИСПРАВЛЕННАЯ ВЕРСИЯ + const personalKey = "prod"; const baseHost = "https://webdev-hw-api.vercel.app"; const postsHost = `${baseHost}/api/v1/${personalKey}/instapro`; @@ -15,11 +15,113 @@ export function getPosts({ token }) { if (response.status === 401) { throw new Error("Нет авторизации"); } + if (!response.ok) { + throw new Error(`Ошибка ${response.status}: ${response.statusText}`); + } + return response.json(); + }) + .then((data) => { + console.log("getPosts response:", data); + return data.posts || []; + }); +} +export function getUserPosts({ token, userId }) { + return fetch(postsHost, { + method: "GET", + headers: { + Authorization: token, + }, + }) + .then((response) => { + if (response.status === 401) { + throw new Error("Нет авторизации"); + } + if (!response.ok) { + throw new Error(`Ошибка ${response.status}: ${response.statusText}`); + } return response.json(); }) .then((data) => { - return data.posts; + console.log("getUserPosts response:", data); + const allPosts = data.posts || []; + return allPosts.filter(post => post.user?.id === userId); + }); +} + +export function likePost({ token, postId }) { + return fetch(`${postsHost}/${postId}/like`, { + method: "POST", + headers: { + Authorization: token, + }, + }) + .then((response) => { + if (response.status === 401) { + throw new Error("Нет авторизации"); + } + if (!response.ok) { + throw new Error(`Ошибка ${response.status}: ${response.statusText}`); + } + 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("Нет авторизации"); + } + if (!response.ok) { + throw new Error(`Ошибка ${response.status}: ${response.statusText}`); + } + return response.json(); + }); +} + +export function addPost({ token, description, imageUrl }) { + if (!description || !imageUrl) { + return Promise.reject(new Error("Описание и изображение обязательны")); + } + + console.log("Sending post data:", { description, imageUrl }); + + return fetch(postsHost, { + method: "POST", + headers: { + Authorization: token, + }, + body: JSON.stringify({ + description: description.trim(), + imageUrl: imageUrl.trim(), + }), + }) + .then(async (response) => { + const responseText = await response.text(); + console.log("Response status:", response.status); + console.log("Response text:", responseText); + + if (response.status === 401) { + throw new Error("Нет авторизации"); + } + if (response.status === 400) { + throw new Error("Неверные данные поста"); + } + if (!response.ok) { + throw new Error(`Ошибка ${response.status}: ${responseText}`); + } + + try { + return JSON.parse(responseText); + } catch (e) { + return { success: true }; + } }); } @@ -32,12 +134,22 @@ export function registerUser({ login, password, name, imageUrl }) { name, imageUrl, }), - }).then((response) => { - if (response.status === 400) { - throw new Error("Такой пользователь уже существует"); - } - return response.json(); - }); + }) + .then(async (response) => { + const responseText = await response.text(); + console.log("Register response:", response.status, responseText); + + if (response.status === 400) { + throw new Error("Такой пользователь уже существует"); + } + if (!response.ok) { + throw new Error(`Ошибка ${response.status}: ${responseText}`); + } + + const data = JSON.parse(responseText); + // Возвращаем пользователя в правильном формате + return { user: data.user }; + }); } export function loginUser({ login, password }) { @@ -47,15 +159,28 @@ export function loginUser({ login, password }) { login, password, }), - }).then((response) => { - if (response.status === 400) { - throw new Error("Неверный логин или пароль"); - } - return response.json(); - }); + }) + .then(async (response) => { + const responseText = await response.text(); + console.log("Login response:", response.status, responseText); + + if (response.status === 400) { + throw new Error("Неверный логин или пароль"); + } + if (response.status === 401) { + throw new Error("Не авторизован"); + } + if (!response.ok) { + throw new Error(`Ошибка ${response.status}: ${responseText}`); + } + + const data = JSON.parse(responseText); + console.log("Parsed login data:", data); + // Возвращаем пользователя в правильном формате + return { user: data.user }; + }); } -// Загружает картинку в облако, возвращает url загруженной картинки export function uploadImage({ file }) { const data = new FormData(); data.append("file", file); @@ -64,6 +189,9 @@ export function uploadImage({ file }) { method: "POST", body: data, }).then((response) => { + if (!response.ok) { + throw new Error(`Ошибка загрузки изображения: ${response.status}`); + } return response.json(); }); -} +} \ No newline at end of file diff --git a/components/add-post-page-component.js b/components/add-post-page-component.js index 8277f093b..c4adb299d 100644 --- a/components/add-post-page-component.js +++ b/components/add-post-page-component.js @@ -1,23 +1,109 @@ +import { renderHeaderComponent } from "./header-component.js"; +import { renderUploadImageComponent } from "./upload-image-component.js"; +import { goToPage } from "../index.js"; +import { POSTS_PAGE } from "../routes.js"; + export function renderAddPostPageComponent({ appEl, onAddPostClick }) { + let imageUrl = ""; + let description = ""; + 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; + console.log("Image URL updated:", imageUrl); + const addButton = document.getElementById("add-button"); + if (addButton) { + addButton.disabled = !imageUrl || !description; + } + }, + }); + + const descriptionInput = document.getElementById("description-input"); + const addButton = document.getElementById("add-button"); + const cancelButton = document.getElementById("cancel-button"); + const errorDiv = document.querySelector(".form-error"); + + descriptionInput.addEventListener("input", (e) => { + description = e.target.value; + console.log("Description updated:", description); + if (addButton) { + addButton.disabled = !imageUrl || !description.trim(); + } + if (errorDiv) { + errorDiv.textContent = ""; + } + }); + + addButton.addEventListener("click", async () => { + console.log("Add button clicked"); + console.log("Current imageUrl:", imageUrl); + console.log("Current description:", description); + + if (!imageUrl) { + errorDiv.textContent = "Пожалуйста, выберите изображение"; + return; + } + + if (!description.trim()) { + errorDiv.textContent = "Пожалуйста, введите описание"; + return; + } + + addButton.disabled = true; + addButton.textContent = "Публикация..."; + + try { + const postData = { + description: description.trim(), + imageUrl: imageUrl, + }; + console.log("Sending post data:", postData); + + await onAddPostClick(postData); + console.log("Post added successfully"); + goToPage(POSTS_PAGE); + } catch (error) { + console.error("Ошибка при добавлении поста:", error); + errorDiv.textContent = error.message || "Ошибка при публикации"; + addButton.disabled = false; + addButton.textContent = "Опубликовать"; + } + }); + + cancelButton.addEventListener("click", () => { + goToPage(POSTS_PAGE); }); }; render(); -} +} \ No newline at end of file diff --git a/components/date-utils.js b/components/date-utils.js new file mode 100644 index 000000000..44c9b7152 --- /dev/null +++ b/components/date-utils.js @@ -0,0 +1,45 @@ +export function formatDistanceToNow(date) { + const now = new Date(); + const diffInSeconds = Math.floor((now - date) / 1000); + + if (diffInSeconds < 0) return "только что"; + + const intervals = [ + { label: "год", seconds: 31536000, threshold: 5 }, + { label: "месяц", seconds: 2592000, threshold: 11 }, + { label: "день", seconds: 86400, threshold: 6 }, + { label: "час", seconds: 3600, threshold: 23 }, + { label: "минуту", seconds: 60, threshold: 59 } + ]; + + for (const interval of intervals) { + const count = Math.floor(diffInSeconds / interval.seconds); + if (count >= 1) { + if (count === 1) { + return `${count} ${interval.label} назад`; + } + let label = interval.label; + if (label === "минуту") label = "минут"; + if (label === "час") label = "часов"; + if (label === "день") { + if (count % 10 === 1 && count % 100 !== 11) label = "день"; + else if (count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 10 || count % 100 >= 20)) label = "дня"; + else label = "дней"; + } + if (label === "месяц") { + if (count % 10 === 1 && count % 100 !== 11) label = "месяц"; + else if (count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 10 || count % 100 >= 20)) label = "месяца"; + else label = "месяцев"; + } + if (label === "год") { + if (count % 10 === 1 && count % 100 !== 11) label = "год"; + else if (count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 10 || count % 100 >= 20)) label = "года"; + else label = "лет"; + } + + return `${count} ${label} назад`; + } + } + + return "только что"; +} \ No newline at end of file diff --git a/components/posts-page-component.js b/components/posts-page-component.js index 662ccdbdc..b4faf881f 100644 --- a/components/posts-page-component.js +++ b/components/posts-page-component.js @@ -1,109 +1,129 @@ import { USER_POSTS_PAGE } from "../routes.js"; import { renderHeaderComponent } from "./header-component.js"; -import { posts, goToPage } from "../index.js"; +import { goToPage, user } from "../index.js"; +import { likePost, dislikePost } from "../api.js"; +import { formatDistanceToNow } from "./date-utils.js"; -export function renderPostsPageComponent({ appEl }) { - // @TODO: реализовать рендер постов из api - console.log("Актуальный список постов:", posts); +const DEFAULT_AVATAR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' fill='%23cccccc'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23666666' font-size='20'%3E👤%3C/text%3E%3C/svg%3E"; - /** - * @TODO: чтобы отформатировать дату создания поста в виде "19 минут назад" - * можно использовать https://date-fns.org/v2.29.3/docs/formatDistanceToNow - */ - const appHtml = ` -
-
- -
`; +export function renderPostsPageComponent({ appEl, posts }) { + const token = user ? `Bearer ${user.token}` : undefined; - appEl.innerHTML = appHtml; + const renderPosts = () => { + const postsHtml = posts.map(post => createPostHTML(post, token)).join(''); + + const appHtml = ` +
+
+ +
+ `; - renderHeaderComponent({ - element: document.querySelector(".header-container"), - }); + appEl.innerHTML = appHtml; - for (let userEl of document.querySelectorAll(".post-header")) { - userEl.addEventListener("click", () => { - goToPage(USER_POSTS_PAGE, { - userId: userEl.dataset.userId, + renderHeaderComponent({ + element: document.querySelector(".header-container"), + }); + + document.querySelectorAll('.like-button').forEach(button => { + button.addEventListener('click', async (event) => { + event.stopPropagation(); + const postId = button.dataset.postId; + const isLiked = button.classList.contains('liked'); + + if (!token) { + alert("Войдите в систему, чтобы ставить лайки"); + return; + } + + try { + if (isLiked) { + await dislikePost({ token, postId }); + } else { + await likePost({ token, postId }); + } + + const { getPosts } = await import("../api.js"); + const newPosts = await getPosts({ token }); + window.posts = newPosts; + renderPostsPageComponent({ appEl, posts: newPosts }); + } catch (error) { + console.error("Ошибка при изменении лайка:", error); + alert("Не удалось поставить лайк. Попробуйте позже."); + } + }); + }); + + document.querySelectorAll(".post-header").forEach((header) => { + header.addEventListener("click", () => { + const userId = header.dataset.userId; + if (userId) { + goToPage(USER_POSTS_PAGE, { userId }); + } }); }); - } + }; + + renderPosts(); } + +function createPostHTML(post, token) { + const date = post.createdAt ? new Date(post.createdAt) : new Date(); + const timeAgo = formatDistanceToNow(date); + + const isLiked = post.isLiked || false; + // ИСПРАВЛЕНО: если likes - массив, берем его длину, иначе само значение + const likeCount = Array.isArray(post.likes) ? post.likes.length : (post.likes || 0); + + const userAvatar = post.user?.imageUrl || DEFAULT_AVATAR; + + const likeButtonHtml = token ? ` + + ` : ` +
+ 🤍 ${likeCount} +
+ `; + + return ` +
  • +
    + ${escapeHtml(post.user?.name || 'Пользователь')} +

    ${escapeHtml(post.user?.name || 'Аноним')}

    +
    + + ${post.imageUrl ? ` +
    + Пост +
    + ` : ''} + +
    + ${likeButtonHtml} +
    + +

    + ${escapeHtml(post.user?.name || 'Аноним')} + ${escapeHtml(post.description || '')} +

    + +

    + ${timeAgo} +

    +
  • + `; +} + +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} \ No newline at end of file diff --git a/components/user-posts-page-component.js b/components/user-posts-page-component.js new file mode 100644 index 000000000..215cb4e5c --- /dev/null +++ b/components/user-posts-page-component.js @@ -0,0 +1,180 @@ +import { renderHeaderComponent } from "./header-component.js"; +import { goToPage, user } from "../index.js"; +import { getUserPosts, likePost, dislikePost } from "../api.js"; +import { formatDistanceToNow } from "./date-utils.js"; + +const DEFAULT_AVATAR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' fill='%23cccccc'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23666666' font-size='20'%3E👤%3C/text%3E%3C/svg%3E"; +const DEFAULT_COVER = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='70' height='70' viewBox='0 0 70 70'%3E%3Crect width='70' height='70' fill='%23cccccc'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23666666' font-size='35'%3E👤%3C/text%3E%3C/svg%3E"; + +export function renderUserPostsPageComponent({ appEl, userId }) { + const token = user ? `Bearer ${user.token}` : undefined; + let posts = []; + + const render = async () => { + appEl.innerHTML = ` +
    +
    +
    +
    +
    +
    + `; + + renderHeaderComponent({ + element: document.querySelector(".header-container"), + }); + + try { + posts = await getUserPosts({ token, userId }); + renderPosts(); + } catch (error) { + console.error("Ошибка загрузки постов пользователя:", error); + appEl.innerHTML = ` +
    +
    +
    +

    ❌ Ошибка загрузки постов

    + +
    +
    + `; + + renderHeaderComponent({ + element: document.querySelector(".header-container"), + }); + + document.getElementById("retry-button")?.addEventListener("click", () => { + renderUserPostsPageComponent({ appEl, userId }); + }); + } + }; + + const renderPosts = () => { + if (!posts || posts.length === 0) { + appEl.innerHTML = ` +
    +
    +
    +

    📭 У пользователя пока нет постов

    +
    +
    + `; + renderHeaderComponent({ + element: document.querySelector(".header-container"), + }); + return; + } + + const userInfo = posts[0]?.user; + const userAvatar = userInfo?.imageUrl || DEFAULT_COVER; + const postsHtml = posts.map(post => createPostHTML(post, token)).join(''); + + const appHtml = ` +
    +
    +
    + ${escapeHtml(userInfo?.name || 'Пользователь')} +

    ${escapeHtml(userInfo?.name || 'Пользователь')}

    +
    + +
    + `; + + appEl.innerHTML = appHtml; + + renderHeaderComponent({ + element: document.querySelector(".header-container"), + }); + + document.querySelectorAll('.like-button').forEach(button => { + button.addEventListener('click', async (event) => { + event.stopPropagation(); + const postId = button.dataset.postId; + const isLiked = button.classList.contains('liked'); + + if (!token) { + alert("Войдите в систему, чтобы ставить лайки"); + return; + } + + try { + if (isLiked) { + await dislikePost({ token, postId }); + } else { + await likePost({ token, postId }); + } + + posts = await getUserPosts({ token, userId }); + renderPosts(); + } catch (error) { + console.error("Ошибка при изменении лайка:", error); + alert("Не удалось поставить лайк. Попробуйте позже."); + } + }); + }); + }; + + render(); +} + +function createPostHTML(post, token) { + const date = post.createdAt ? new Date(post.createdAt) : new Date(); + const timeAgo = formatDistanceToNow(date); + + const isLiked = post.isLiked || false; + // ИСПРАВЛЕНО: если likes - массив, берем его длину, иначе само значение + const likeCount = Array.isArray(post.likes) ? post.likes.length : (post.likes || 0); + const userAvatar = post.user?.imageUrl || DEFAULT_AVATAR; + + const likeButtonHtml = token ? ` + + ` : ` +
    + 🤍 ${likeCount} +
    + `; + + return ` +
  • +
    + ${escapeHtml(post.user?.name || 'Пользователь')} +

    ${escapeHtml(post.user?.name || 'Аноним')}

    +
    + + ${post.imageUrl ? ` +
    + Пост +
    + ` : ''} + +
    + ${likeButtonHtml} +
    + +

    + ${escapeHtml(post.user?.name || 'Аноним')} + ${escapeHtml(post.description || '')} +

    + +

    + ${timeAgo} +

    +
  • + `; +} + +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} \ No newline at end of file diff --git a/index.js b/index.js index 7f1817c75..24aeab324 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ -import { getPosts } from "./api.js"; +import { getPosts, addPost } from "./api.js"; import { renderAddPostPageComponent } from "./components/add-post-page-component.js"; import { renderAuthPageComponent } from "./components/auth-page-component.js"; import { @@ -10,6 +10,7 @@ import { } from "./routes.js"; import { renderPostsPageComponent } from "./components/posts-page-component.js"; import { renderLoadingPageComponent } from "./components/loading-page-component.js"; +import { renderUserPostsPageComponent } from "./components/user-posts-page-component.js"; import { getUserFromLocalStorage, removeUserFromLocalStorage, @@ -31,9 +32,6 @@ export const logout = () => { goToPage(POSTS_PAGE); }; -/** - * Включает страницу приложения - */ export const goToPage = (newPage, data) => { if ( [ @@ -45,7 +43,6 @@ export const goToPage = (newPage, data) => { ].includes(newPage) ) { if (newPage === ADD_POSTS_PAGE) { - /* Если пользователь не авторизован, то отправляем его на страницу авторизации перед добавлением поста */ page = user ? ADD_POSTS_PAGE : AUTH_PAGE; return renderApp(); } @@ -62,21 +59,20 @@ export const goToPage = (newPage, data) => { }) .catch((error) => { console.error(error); - goToPage(POSTS_PAGE); + page = POSTS_PAGE; + posts = []; + renderApp(); }); } if (newPage === USER_POSTS_PAGE) { - // @@TODO: реализовать получение постов юзера из API - console.log("Открываю страницу пользователя: ", data.userId); page = USER_POSTS_PAGE; - posts = []; + window.currentUserId = data?.userId; return renderApp(); } page = newPage; renderApp(); - return; } @@ -85,6 +81,7 @@ export const goToPage = (newPage, data) => { const renderApp = () => { const appEl = document.getElementById("app"); + if (page === LOADING_PAGE) { return renderLoadingPageComponent({ appEl, @@ -109,10 +106,19 @@ const renderApp = () => { if (page === ADD_POSTS_PAGE) { return renderAddPostPageComponent({ appEl, - onAddPostClick({ description, imageUrl }) { - // @TODO: реализовать добавление поста в API - console.log("Добавляю пост...", { description, imageUrl }); - goToPage(POSTS_PAGE); + onAddPostClick: async ({ description, imageUrl }) => { + try { + const token = getToken(); + if (!token) { + throw new Error("Необходимо авторизоваться"); + } + + await addPost({ token, description, imageUrl }); + goToPage(POSTS_PAGE); + } catch (error) { + console.error("Ошибка при добавлении поста:", error); + throw error; + } }, }); } @@ -120,14 +126,17 @@ const renderApp = () => { if (page === POSTS_PAGE) { return renderPostsPageComponent({ appEl, + posts, }); } if (page === USER_POSTS_PAGE) { - // @TODO: реализовать страницу с фотографиями отдельного пользвателя - appEl.innerHTML = "Здесь будет страница фотографий пользователя"; - return; + return renderUserPostsPageComponent({ + appEl, + userId: window.currentUserId, + }); } }; -goToPage(POSTS_PAGE); +// Запускаем приложение +goToPage(POSTS_PAGE); \ No newline at end of file diff --git a/styles.css b/styles.css index 57bc08b16..8221ddfa6 100644 --- a/styles.css +++ b/styles.css @@ -196,3 +196,42 @@ width: 100%; text-align: center; } +.error-container { + text-align: center; + padding: 40px; +} + +.empty-state { + text-align: center; + padding: 40px; + color: #666; +} + +.like-button { + cursor: pointer; + transition: transform 0.2s; +} + +.like-button:hover { + transform: scale(1.1); +} + +.like-button.liked img { + filter: drop-shadow(0 0 2px rgba(255, 0, 0, 0.5)); +} + +.like-placeholder { + width: 32px; + height: 32px; +} + +.textarea { + font-family: StratosSkyeng; + resize: vertical; +} + +.posts-user-header { + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid #e0e0e0; +} \ No newline at end of file