From 3b79014dd9d39959d01d16d3f2c83a6be4f9b7fe Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Tue, 16 Dec 2025 17:34:13 +0300 Subject: [PATCH 1/3] implement comments demo api --- src/components/Chat/index.jsx | 38 +++++++++++++++++++++++++++++++++-- src/services/chat/index.js | 29 ++++++++++++++++++++++++++ src/services/index.js | 3 ++- 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/services/chat/index.js diff --git a/src/components/Chat/index.jsx b/src/components/Chat/index.jsx index 6f7abf7..94d013a 100644 --- a/src/components/Chat/index.jsx +++ b/src/components/Chat/index.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from "react"; import cx from "classnames"; import { useCurrentUser, useLocalStorage, useMessageChannel } from "src/hooks" +import { ChatService } from "src/services" import styles from "./index.module.css" /** @@ -30,6 +31,30 @@ export function Chat({ movieId }) { // Ключ в localStorage зависит от movieId, чтобы чат был привязан к конкретному фильму const { value: messages, setValue: setMessages } = useLocalStorage(`flickmate_chat_${movieId}`, []) + // Загружаем сообщения "с сервера" при открытии чата + useEffect(() => { + // Используется публичный тестовый API, + // который не поддерживает чаты по фильмам, + // поэтому movieId здесь не передаётся + ChatService.getMessages().then(apiMessages => { + // Берём 5 псевдослучайных сообщений + // и приводим объект к нужному для отображения формату + const formatted = apiMessages + .sort(() => Math.random() - 0.5) + .slice(0, 5) + .map(item => ({ + id: `api_${item.id}`, + sender: item.email, + text: item.body, + })); + + // Если в localStorage ещё нет сообщений — инициализируем чат данными с сервера + setMessages(prev => prev.length ? prev : formatted); + }).catch(() => { + // TODO: Добавить обработку ошибки, если нужно + }); + }, [movieId]); + // Локальное состояние поля ввода const [inputValue, setInputValue] = useState("") @@ -52,12 +77,21 @@ export function Chat({ movieId }) { text: inputValue.trim(), }; + // POST-запрос используется только для демонстрации отправки данных на сервер + // Успешную отправку сообщения и ответ можно увидеть на вкладке Network + // Реальное состояние чата хранится локально (localStorage + BroadcastChannel) + ChatService.sendMessage({ + movieId, + sender: currentUser, + text: inputValue, + }).catch(() => { + // TODO: Добавить обработку ошибки, если нужно + }); + // Добавляем сообщение в текущую вкладку setMessages(prev => [...prev, message]); - // Отправляем сообщение через BroadcastChannel в другие вкладки sendChannelMessage(message); - // Очищаем поле ввода после отправки сообщения setInputValue("") } diff --git a/src/services/chat/index.js b/src/services/chat/index.js new file mode 100644 index 0000000..6c745c5 --- /dev/null +++ b/src/services/chat/index.js @@ -0,0 +1,29 @@ +const API_URL = "https://jsonplaceholder.typicode.com/comments"; + +/** + * Сервис для работы с сообщениями чата + */ +export class ChatService { + /** + * Загружает сообщения с сервера + * Используется для демонстрации GET-запроса + */ + static async getMessages() { + const response = await fetch(`${API_URL}?postId=1`); + return response.json(); + } + + /** + * Отправляет сообщение на сервер + * Используется для демонстрации POST-запроса + */ + static async sendMessage(message) { + return fetch(API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(message), + }); + } +} diff --git a/src/services/index.js b/src/services/index.js index 58617a8..723434b 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -1,2 +1,3 @@ export * from "./movie" -export * from "./user" \ No newline at end of file +export * from "./user" +export * from "./chat" \ No newline at end of file From 664fd38548c01b0666d4688cffaa0f9f920ae8b1 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Tue, 16 Dec 2025 20:02:58 +0300 Subject: [PATCH 2/3] use fake id --- src/components/Chat/index.jsx | 24 ++++++++++------------ src/services/chat/index.js | 10 +++++++-- src/utils/stringToNumberInRange.js | 33 ++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 src/utils/stringToNumberInRange.js diff --git a/src/components/Chat/index.jsx b/src/components/Chat/index.jsx index 94d013a..45ac939 100644 --- a/src/components/Chat/index.jsx +++ b/src/components/Chat/index.jsx @@ -31,12 +31,9 @@ export function Chat({ movieId }) { // Ключ в localStorage зависит от movieId, чтобы чат был привязан к конкретному фильму const { value: messages, setValue: setMessages } = useLocalStorage(`flickmate_chat_${movieId}`, []) - // Загружаем сообщения "с сервера" при открытии чата + // Загружаем сообщения с публичного тестового API при открытии чата useEffect(() => { - // Используется публичный тестовый API, - // который не поддерживает чаты по фильмам, - // поэтому movieId здесь не передаётся - ChatService.getMessages().then(apiMessages => { + ChatService.getMessages(movieId).then(apiMessages => { // Берём 5 псевдослучайных сообщений // и приводим объект к нужному для отображения формату const formatted = apiMessages @@ -48,7 +45,8 @@ export function Chat({ movieId }) { text: item.body, })); - // Если в localStorage ещё нет сообщений — инициализируем чат данными с сервера + // Если в localStorage ещё нет сообщений – инициализируем чат данными с сервера + // prev здесь – это messages, полученные ранее из useLocalStorage setMessages(prev => prev.length ? prev : formatted); }).catch(() => { // TODO: Добавить обработку ошибки, если нужно @@ -84,16 +82,16 @@ export function Chat({ movieId }) { movieId, sender: currentUser, text: inputValue, + }).then(() => { // сервер вернул 201 – сообщение успешно добавлено + // Добавляем сообщение в текущую вкладку + setMessages(prev => [...prev, message]) + // Отправляем сообщение через BroadcastChannel в другие вкладки + sendChannelMessage(message) + // Очищаем поле ввода после отправки сообщения + setInputValue("") }).catch(() => { // TODO: Добавить обработку ошибки, если нужно }); - - // Добавляем сообщение в текущую вкладку - setMessages(prev => [...prev, message]); - // Отправляем сообщение через BroadcastChannel в другие вкладки - sendChannelMessage(message); - // Очищаем поле ввода после отправки сообщения - setInputValue("") } // Скролл к последнему сообщению и фокус на input при отправке/получении нового сообщения diff --git a/src/services/chat/index.js b/src/services/chat/index.js index 6c745c5..c525367 100644 --- a/src/services/chat/index.js +++ b/src/services/chat/index.js @@ -1,3 +1,5 @@ +import stringToNumberInRange from "src/utils/stringToNumberInRange" + const API_URL = "https://jsonplaceholder.typicode.com/comments"; /** @@ -8,8 +10,12 @@ export class ChatService { * Загружает сообщения с сервера * Используется для демонстрации GET-запроса */ - static async getMessages() { - const response = await fetch(`${API_URL}?postId=1`); + static async getMessages(movieId) { + // В jsonplaceholder всего 100 posts, + // поэтому для использования movieId в качестве postId + // генерируем на основе movieId число от 1 до 100 + const id = stringToNumberInRange(movieId, 1, 100) + const response = await fetch(`${API_URL}?postId=${id}`); return response.json(); } diff --git a/src/utils/stringToNumberInRange.js b/src/utils/stringToNumberInRange.js new file mode 100644 index 0000000..ca3ab62 --- /dev/null +++ b/src/utils/stringToNumberInRange.js @@ -0,0 +1,33 @@ +/** + * Преобразует произвольную строку в число в диапазоне [min, max]. + * Используется, например, чтобы назначить "случайное", но детерминированное число для строки. + * + * @param {string} str - входная строка (например, ID фильма или видео) + * @param {number} min - минимальное число диапазона (включительно) + * @param {number} max - максимальное число диапазона (включительно) + * @returns {number} число в диапазоне [min, max] + */ +export default function stringToNumberInRange(str, min = 1, max = 100) { + // вычисляем простой числовой "хэш" из строки + // складываем коды всех символов строки, умножая каждый на позицию + // Это даёт одно и то же число для одной и той же строки + let hash = 0; + for (let i = 0; i < str.length; i++) { + // str.charCodeAt(i) возвращает числовой код символа + // Умножаем на i+1, чтобы порядок символов влиял на результат + hash += str.charCodeAt(i) * (i + 1); + } + + // определяем длину диапазона чисел + const range = max - min + 1; // например, 100 - 1 + 1 = 100 + + // используем остаток от деления (mod) для сжатия числа в диапазон [0, range-1] + // hash % range может быть отрицательным, если hash отрицательный + // Чтобы гарантировать положительное число, добавляем range и снова берём остаток + const normalized = ((hash % range) + range) % range; + + // сдвигаем диапазон с [0, range-1] в [min, max] + const result = normalized + min; + + return result; +} \ No newline at end of file From f4dd2a26a36d140b3fc12d6c6a6e5d2e4c5afca3 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Tue, 16 Dec 2025 20:38:08 +0300 Subject: [PATCH 3/3] implement movies api --- src/data/movies.js => public/api/movies.json | 54 ++++++++++---------- src/components/Chat/index.jsx | 4 +- src/components/MovieModal/index.jsx | 7 ++- src/pages/Home/index.jsx | 7 ++- src/pages/Watch/index.jsx | 7 ++- src/services/movie/index.js | 10 ++-- 6 files changed, 50 insertions(+), 39 deletions(-) rename src/data/movies.js => public/api/movies.json (82%) diff --git a/src/data/movies.js b/public/api/movies.json similarity index 82% rename from src/data/movies.js rename to public/api/movies.json index 9512b55..6a418e6 100644 --- a/src/data/movies.js +++ b/public/api/movies.json @@ -1,4 +1,4 @@ -export default [ +[ { "id": "-Cs3GyzRB2k?si=gON_3Ryi-msckxeM", "title": "Аватар", @@ -7,7 +7,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1599028/4adf61aa-3cb7-4381-9245-523971e5b4c8/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1599028/4adf61aa-3cb7-4381-9245-523971e5b4c8/600x900", "metadata": [ - { name: "Год производства", value: "2009" } + { "name": "Год производства", "value": "2009" } ] }, { @@ -18,13 +18,13 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1600647/430042eb-ee69-4818-aed0-a312400a26bf/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1600647/430042eb-ee69-4818-aed0-a312400a26bf/600x900", "metadata": [ - { name: "Год производства", value: "2014" }, - { name: "Жанр", value: "фантастика, драма, приключения" }, - { name: "Слоган", value: "«Следующий шаг человечества станет величайшим»" }, - { name: "Режиссер", value: "Кристофер Нолан" }, - { name: "Продюсер", value: "Кристофер Нолан, Линда Обст, Эмма Томас" }, - { name: "Бюджет", value: "$165 000 000" }, - { name: "Время", value: "2 ч 49 мин" } + { "name": "Год производства", "value": "2014" }, + { "name": "Жанр", "value": "фантастика, драма, приключения" }, + { "name": "Слоган", "value": "«Следующий шаг человечества станет величайшим»" }, + { "name": "Режиссер", "value": "Кристофер Нолан" }, + { "name": "Продюсер", "value": "Кристофер Нолан, Линда Обст, Эмма Томас" }, + { "name": "Бюджет", "value": "$165 000 000" }, + { "name": "Время", "value": "2 ч 49 мин" } ] }, { @@ -35,7 +35,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1629390/8ab9a119-dd74-44f0-baec-0629797483d7/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1629390/8ab9a119-dd74-44f0-baec-0629797483d7/600x900", "metadata": [ - { name: "Год производства", value: "2010" } + { "name": "Год производства", "value": "2010" } ] }, { @@ -46,7 +46,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1773646/af92d310-4ae5-4daa-b42c-5bcc380c2e6e/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1773646/af92d310-4ae5-4daa-b42c-5bcc380c2e6e/600x900", "metadata": [ - { name: "Год производства", value: "2018" } + { "name": "Год производства", "value": "2018" } ] }, { @@ -57,7 +57,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/4774061/cf1970bc-3f08-4e0e-a095-2fb57c3aa7c6/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/4774061/cf1970bc-3f08-4e0e-a095-2fb57c3aa7c6/600x900", "metadata": [ - { name: "Год производства", value: "1999" } + { "name": "Год производства", "value": "1999" } ] }, { @@ -68,7 +68,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1600647/ae22f153-9715-41bb-adb4-f648b3e16092/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1600647/ae22f153-9715-41bb-adb4-f648b3e16092/600x900", "metadata": [ - { name: "Год производства", value: "2019" } + { "name": "Год производства", "value": "2019" } ] }, { @@ -79,7 +79,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/4303601/9eb762d6-4cdd-464f-9937-aebf30067acc/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/4303601/9eb762d6-4cdd-464f-9937-aebf30067acc/600x900", "metadata": [ - { name: "Год производства", value: "2021" } + { "name": "Год производства", "value": "2021" } ] }, { @@ -90,7 +90,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1629390/9e9e2b2c-a3c1-462e-8d84-e6a19fbe5b9c/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1629390/9e9e2b2c-a3c1-462e-8d84-e6a19fbe5b9c/600x900", "metadata": [ - { name: "Год производства", value: "1997" } + { "name": "Год производства", "value": "1997" } ] }, { @@ -101,7 +101,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1773646/2e6ab20b-7cf1-49e7-b465-bd5a71c13fa3/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1773646/2e6ab20b-7cf1-49e7-b465-bd5a71c13fa3/600x900", "metadata": [ - { name: "Год производства", value: "2014" } + { "name": "Год производства", "value": "2014" } ] }, { @@ -112,7 +112,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/10809116/b722ab4d-497b-4a62-b243-95ca989401ff/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/10809116/b722ab4d-497b-4a62-b243-95ca989401ff/600x900", "metadata": [ - { name: "Год производства", value: "2025" } + { "name": "Год производства", "value": "2025" } ] }, { @@ -123,7 +123,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1599028/0fa5bf50-d5ad-446f-a599-b26d070c8b99/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1599028/0fa5bf50-d5ad-446f-a599-b26d070c8b99/600x900", "metadata": [ - { name: "Год производства", value: "2008" } + { "name": "Год производства", "value": "2008" } ] }, { @@ -134,7 +134,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/4303601/bb966b79-5b10-485d-88d7-fb6aeb79b185/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/4303601/bb966b79-5b10-485d-88d7-fb6aeb79b185/600x900", "metadata": [ - { name: "Год производства", value: "2016" } + { "name": "Год производства", "value": "2016" } ] }, { @@ -145,7 +145,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1898899/972b7f43-9677-40ce-a9bc-02a88ad3919d/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1898899/972b7f43-9677-40ce-a9bc-02a88ad3919d/600x900", "metadata": [ - { name: "Год производства", value: "2012" } + { "name": "Год производства", "value": "2012" } ] }, { @@ -156,7 +156,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/10893610/2dd14742-f241-42ca-9db4-331e3a483c50/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/10893610/2dd14742-f241-42ca-9db4-331e3a483c50/600x900", "metadata": [ - { name: "Год производства", value: "1991" } + { "name": "Год производства", "value": "1991" } ] }, { @@ -167,7 +167,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/4774061/c8e2f069-15f1-4803-95c0-aba858fec360/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/4774061/c8e2f069-15f1-4803-95c0-aba858fec360/600x900", "metadata": [ - { name: "Год производства", value: "2008" } + { "name": "Год производства", "value": "2008" } ] }, { @@ -178,7 +178,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1599028/73cf2ed0-fd52-47a2-9e26-74104360786a/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1599028/73cf2ed0-fd52-47a2-9e26-74104360786a/600x900", "metadata": [ - { name: "Год производства", value: "1985" } + { "name": "Год производства", "value": "1985" } ] }, { @@ -189,7 +189,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1900788/6f631486-e947-487d-94d6-41c2b5a8f5a0/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1900788/6f631486-e947-487d-94d6-41c2b5a8f5a0/600x900", "metadata": [ - { name: "Год производства", value: "2015" } + { "name": "Год производства", "value": "2015" } ] }, { @@ -200,7 +200,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/9784475/70c75cf3-f456-4474-a900-9a38c1bb2987/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/9784475/70c75cf3-f456-4474-a900-9a38c1bb2987/600x900", "metadata": [ - { name: "Год производства", value: "2023" } + { "name": "Год производства", "value": "2023" } ] }, { @@ -211,7 +211,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/6201401/db4fbef1-466a-4dec-9b7a-d4f13eb45738/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/6201401/db4fbef1-466a-4dec-9b7a-d4f13eb45738/600x900", "metadata": [ - { name: "Год производства", value: "2021" } + { "name": "Год производства", "value": "2021" } ] }, { @@ -222,7 +222,7 @@ export default [ "img": "https://avatars.mds.yandex.net/get-kinopoisk-image/1946459/5ae82f4b-fd6a-46b5-b5ba-897106eb1eae/136x204", "imgBig": "https://avatars.mds.yandex.net/get-kinopoisk-image/1946459/5ae82f4b-fd6a-46b5-b5ba-897106eb1eae/600x900", "metadata": [ - { name: "Год производства", value: "2018" } + { "name": "Год производства", "value": "2018" } ] } ] \ No newline at end of file diff --git a/src/components/Chat/index.jsx b/src/components/Chat/index.jsx index 45ac939..8de7f2d 100644 --- a/src/components/Chat/index.jsx +++ b/src/components/Chat/index.jsx @@ -49,7 +49,7 @@ export function Chat({ movieId }) { // prev здесь – это messages, полученные ранее из useLocalStorage setMessages(prev => prev.length ? prev : formatted); }).catch(() => { - // TODO: Добавить обработку ошибки, если нужно + // TODO: Добавить обработку ошибки }); }, [movieId]); @@ -90,7 +90,7 @@ export function Chat({ movieId }) { // Очищаем поле ввода после отправки сообщения setInputValue("") }).catch(() => { - // TODO: Добавить обработку ошибки, если нужно + // TODO: Добавить обработку ошибки }); } diff --git a/src/components/MovieModal/index.jsx b/src/components/MovieModal/index.jsx index 4bbb5db..68bcb11 100644 --- a/src/components/MovieModal/index.jsx +++ b/src/components/MovieModal/index.jsx @@ -20,8 +20,11 @@ export function MovieModal({ movieId, isOpen, onClose }) { // Получение данных фильма при смене movieId useEffect(() => { if (movieId) { - const movie = MovieService.getById(movieId); - setMovie(movie); + MovieService.getById(movieId).then((data) => { + setMovie(data); + }).catch(() => { + // TODO: Добавить обработку ошибки + }); } else { setMovie({}); } diff --git a/src/pages/Home/index.jsx b/src/pages/Home/index.jsx index 293ba34..e67bd9f 100644 --- a/src/pages/Home/index.jsx +++ b/src/pages/Home/index.jsx @@ -11,8 +11,11 @@ export function HomePage() { useEffect(() => { // Получаем все фильмы из модели - const movies = MovieService.getAll(); - setMovies(movies); + MovieService.getAll().then((data) => { + setMovies(data); + }).catch(() => { + // TODO: Добавить обработку ошибки + }); }, []) return ( diff --git a/src/pages/Watch/index.jsx b/src/pages/Watch/index.jsx index 76f3321..f27d1d4 100644 --- a/src/pages/Watch/index.jsx +++ b/src/pages/Watch/index.jsx @@ -15,8 +15,11 @@ export function WatchPage() { const decodedMovieId = decodeURIComponent(movieId); // Получаем информацию о фильме из модели - const movie = MovieService.getById(decodedMovieId); - setMovie(movie); + MovieService.getById(decodedMovieId).then((data) => { + setMovie(data); + }).catch(() => { + // TODO: Добавить обработку ошибки + }); }, [movieId]) return ( diff --git a/src/services/movie/index.js b/src/services/movie/index.js index fdfab36..ea7996a 100644 --- a/src/services/movie/index.js +++ b/src/services/movie/index.js @@ -1,4 +1,4 @@ -import movies from "src/data/movies.js"; +const API_URL = import.meta.env.BASE_URL + "api"; /** * Класс модели фильмов. @@ -9,8 +9,9 @@ export class MovieService { * Возвращает все фильмы * @returns {Array} Массив объектов фильмов */ - static getAll() { - return movies; + static async getAll() { + const response = await fetch(`${API_URL}/movies.json`); + return response.json(); } /** @@ -18,7 +19,8 @@ export class MovieService { * @param {number} id - Идентификатор фильма * @returns {Object|undefined} Объект фильма или undefined, если не найден */ - static getById(id) { + static async getById(id) { + const movies = await MovieService.getAll() return movies.find(movie => movie.id === id); } } \ No newline at end of file