From d64e384f6ad41bd820fbf19e24b8dbb12720ade3 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Sat, 18 Oct 2025 21:24:20 +0300 Subject: [PATCH 01/10] added watch page --- package.json | 2 +- src/index.html | 4 +-- src/scripts/index.js | 6 ++++ src/scripts/views/index-header-view.js | 23 ++++++++++++++ src/scripts/views/movie-list-view.js | 4 +-- src/scripts/views/movie-modal-view.js | 13 +------- src/scripts/views/movie-watch-view.js | 20 ++++++++++++ src/scripts/views/watch-header-view.js | 23 ++++++++++++++ src/scripts/watch.js | 44 ++++++++++++++++++++++++++ src/watch.html | 23 ++++++++++++++ 10 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 src/scripts/views/index-header-view.js create mode 100644 src/scripts/views/movie-watch-view.js create mode 100644 src/scripts/views/watch-header-view.js create mode 100644 src/scripts/watch.js create mode 100644 src/watch.html diff --git a/package.json b/package.json index 9a625f2..5c5a39d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "Flickmate", "version": "1.0.0", "description": "", - "source": "src/index.html", + "source": "src/*.html", "scripts": { "start": "parcel", "build": "parcel build" diff --git a/src/index.html b/src/index.html index d441d1c..8c4f422 100644 --- a/src/index.html +++ b/src/index.html @@ -16,9 +16,7 @@
-
-

Каталог фильмов

-
+
diff --git a/src/scripts/index.js b/src/scripts/index.js index 7bc0ed0..87cf8b9 100644 --- a/src/scripts/index.js +++ b/src/scripts/index.js @@ -1,3 +1,4 @@ +import IndexHeaderView from "./views/index-header-view"; import MovieListView from "./views/movie-list-view"; import MovieModel from "./models/movie-model"; @@ -6,10 +7,14 @@ import MovieModel from "./models/movie-model"; * Отвечает за инициализацию и рендер списка фильмов. */ class IndexPage { + /** @type {IndexHeaderView} Хэдер */ + indexHeaderView /** @type {MovieListView} Список фильмов */ movieListView constructor() { + // Создаём View хэдера + this.indexHeaderView = new IndexHeaderView("#page-header"); // Получаем все фильмы из модели const movies = MovieModel.getAll(); // Создаём View для списка фильмов @@ -18,6 +23,7 @@ class IndexPage { /** Рендерит главную страницу */ render() { + this.indexHeaderView.render(); this.movieListView.render(); } } diff --git a/src/scripts/views/index-header-view.js b/src/scripts/views/index-header-view.js new file mode 100644 index 0000000..0ed5052 --- /dev/null +++ b/src/scripts/views/index-header-view.js @@ -0,0 +1,23 @@ +// watch-view.js +import BaseView from "./base-view.js"; + +export default class IndexHeaderView extends BaseView { + constructor(selector) { + super(selector); + } + + /** Метод для рендера HTML (обязательный для BaseView) */ + _createInnerHTML() { + return "

Каталог фильмов

"; + } + + /** Убирает события перед перерендером (BaseView) */ + _detachEvents() { + return; + } + + /** Добавляет события после рендера (BaseView) */ + _attachEvents() { + return; + } +} diff --git a/src/scripts/views/movie-list-view.js b/src/scripts/views/movie-list-view.js index 11601c2..1d53438 100644 --- a/src/scripts/views/movie-list-view.js +++ b/src/scripts/views/movie-list-view.js @@ -26,10 +26,10 @@ export default class MovieListView extends BaseView { * @param {Object} movie - Объект фильма * @returns {string} HTML-код карточки */ - #createItem({ id, link, title, subtitle, img, details }) { + #createItem({ id, title, subtitle, img, details }) { return `
- +
${title}
diff --git a/src/scripts/views/movie-modal-view.js b/src/scripts/views/movie-modal-view.js index 7e12f10..47eddcd 100644 --- a/src/scripts/views/movie-modal-view.js +++ b/src/scripts/views/movie-modal-view.js @@ -68,7 +68,7 @@ export default class MovieModalView extends BaseView {
- Смотреть вместе → + Смотреть вместе → @@ -86,11 +86,6 @@ export default class MovieModalView extends BaseView { alert("Settings are not implemented"); } - /** TODO: Переход на страницу просмотра фильма (заглушка) */ - #navigateToWatchPage(movieId) { - alert("Watch page is not implemented"); - } - /** * Обработчик кликов по кнопкам модалки * @param {MouseEvent} event @@ -104,12 +99,6 @@ export default class MovieModalView extends BaseView { event.preventDefault(); this.#showSettings(); } - - const watchBtn = event.target.closest(".movie-modal-watch-btn"); - if (watchBtn) { - event.preventDefault(); - this.#navigateToWatchPage(this._data.id); - } } /** Убирает события перед перерендером (BaseView) */ diff --git a/src/scripts/views/movie-watch-view.js b/src/scripts/views/movie-watch-view.js new file mode 100644 index 0000000..5425251 --- /dev/null +++ b/src/scripts/views/movie-watch-view.js @@ -0,0 +1,20 @@ +// watch-view.js +import BaseView from "../views/base-view.js"; + +export default class MovieWatchView extends BaseView { + constructor(selector) { + super(selector); + } + + _createInnerHTML() { + return ""; + } + + _attachEvents() { + return; + } + + _detachEvents() { + return; + } +} diff --git a/src/scripts/views/watch-header-view.js b/src/scripts/views/watch-header-view.js new file mode 100644 index 0000000..24128c2 --- /dev/null +++ b/src/scripts/views/watch-header-view.js @@ -0,0 +1,23 @@ +// watch-view.js +import BaseView from "./base-view.js"; + +export default class WatchHeaderView extends BaseView { + constructor(selector, initialData = {}) { + super(selector, initialData); + } + + /** Метод для рендера HTML (обязательный для BaseView) */ + _createInnerHTML() { + return `

${this._data.title}

`; + } + + /** Убирает события перед перерендером (BaseView) */ + _detachEvents() { + return; + } + + /** Добавляет события после рендера (BaseView) */ + _attachEvents() { + return; + } +} diff --git a/src/scripts/watch.js b/src/scripts/watch.js new file mode 100644 index 0000000..98bc5ff --- /dev/null +++ b/src/scripts/watch.js @@ -0,0 +1,44 @@ +import WatchHeaderView from "./views/watch-header-view"; +import MovieWatchView from "./views/movie-watch-view.js"; +import MovieModel from "./models/movie-model.js"; + +/** + * Класс страницы совместного просмотра фильма. + * Отвечает за инициализацию и рендер экрана совместного просмотра. + */ +class WatchPage { + /** @type {WatchHeaderView} Хэдер */ + watchHeaderView + /** @type {MovieWatchView} Экран совместного просмотра */ + movieWatchView + + constructor() { + // Получаем id фильма из ?movie_id + const params = new URLSearchParams(window.location.search); + const movieId = Number(params.get("movie_id")); + + // Получаем информацию о фильме из модели + const movie = MovieModel.getById(movieId); + + // Создаём View хэдера + this.watchHeaderView = new WatchHeaderView("#page-header", movie); + // Создаём View совместного просмотра + this.movieWatchView = new MovieWatchView("#movie-watch-container", movie); + } + + /** Рендерит страницу совместного просмотра */ + render() { + this.watchHeaderView.render(); + this.movieWatchView.render(); + } +} + +/** + * Инициализация приложения после полной загрузки DOM + * - чтобы все селекторы уже существовали + * - window.watchPage даёт доступ к корневому объекту в консоли браузера (для отладки) + */ +document.addEventListener("DOMContentLoaded", () => { + window.watchPage = new WatchPage(); + window.watchPage.render(); +}); \ No newline at end of file diff --git a/src/watch.html b/src/watch.html new file mode 100644 index 0000000..838c81d --- /dev/null +++ b/src/watch.html @@ -0,0 +1,23 @@ + + + + + + + Flickmate + + + + + + + + +
+ +
+
+ + + + \ No newline at end of file From 73be8e8a0a3dad0da4ae9609b581fc44e4f93747 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Sat, 18 Oct 2025 21:35:48 +0300 Subject: [PATCH 02/10] added back button --- src/scripts/views/watch-header-view.js | 2 +- src/styles/common.css | 5 +++++ src/styles/form.css | 1 - src/styles/movie-list.css | 1 - 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/scripts/views/watch-header-view.js b/src/scripts/views/watch-header-view.js index 24128c2..482f56d 100644 --- a/src/scripts/views/watch-header-view.js +++ b/src/scripts/views/watch-header-view.js @@ -8,7 +8,7 @@ export default class WatchHeaderView extends BaseView { /** Метод для рендера HTML (обязательный для BaseView) */ _createInnerHTML() { - return `

${this._data.title}

`; + return `

 ${this._data.title}

`; } /** Убирает события перед перерендером (BaseView) */ diff --git a/src/styles/common.css b/src/styles/common.css index 17eaea2..3b42311 100644 --- a/src/styles/common.css +++ b/src/styles/common.css @@ -64,6 +64,11 @@ h3 { color: var(--color-base2); } +a { + color: var(--color-h1); + text-decoration: none; +} + /* header */ .main-header { position: sticky; diff --git a/src/styles/form.css b/src/styles/form.css index ddc774d..af8405d 100644 --- a/src/styles/form.css +++ b/src/styles/form.css @@ -8,7 +8,6 @@ align-items: center; justify-content: center; border: none; - text-decoration: none; cursor: pointer; } diff --git a/src/styles/movie-list.css b/src/styles/movie-list.css index dc9f170..046940e 100644 --- a/src/styles/movie-list.css +++ b/src/styles/movie-list.css @@ -9,7 +9,6 @@ } .movie-item a { - text-decoration: none; display: flex; gap: calc(var(--spacing) * 3); text-align: left; From fe39dc753755e4d295594df48e2c7717295000f2 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Sun, 19 Oct 2025 00:20:39 +0300 Subject: [PATCH 03/10] implemented simple broadcast chat --- .../adapters/broadcast-channel-adapter.js | 26 +++++ src/scripts/models/user-model.js | 27 ++++++ src/scripts/views/index-header-view.js | 1 - src/scripts/views/movie-modal-view.js | 12 +-- src/scripts/views/movie-watch-view.js | 94 +++++++++++++++++-- src/scripts/views/watch-header-view.js | 1 - src/styles/common.css | 13 ++- src/styles/movie-watch.css | 94 +++++++++++++++++++ src/watch.html | 1 + 9 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 src/scripts/adapters/broadcast-channel-adapter.js create mode 100644 src/scripts/models/user-model.js create mode 100644 src/styles/movie-watch.css diff --git a/src/scripts/adapters/broadcast-channel-adapter.js b/src/scripts/adapters/broadcast-channel-adapter.js new file mode 100644 index 0000000..f469a58 --- /dev/null +++ b/src/scripts/adapters/broadcast-channel-adapter.js @@ -0,0 +1,26 @@ +export default class BroadcastChannelAdapter { + #channel + + constructor(channelName) { + this.#channel = new BroadcastChannel(channelName); + } + + /** + * Отправка сообщения + * @param {any} message + */ + send(message) { + this.#channel.postMessage(message); + } + + /** + * Подписка на новые сообщения + * @param {function(any):void} callback + */ + onMessage(callback) { + this.#channel.onmessage = (event) => callback(event.data); + } +} + +/** Фабрика для создания канала сообщений */ +export const createMessageChannel = (channelName) => new BroadcastChannelAdapter(channelName); \ No newline at end of file diff --git a/src/scripts/models/user-model.js b/src/scripts/models/user-model.js new file mode 100644 index 0000000..71c5f4e --- /dev/null +++ b/src/scripts/models/user-model.js @@ -0,0 +1,27 @@ +// генерируем имя пользователя +// определяем функцию для генерации имени пользователя +// и сразу же её вызываем, записывая значение в переменную +const username = (() => { + const adjectives = ["Смешной", "Быстрый", "Ловкий", "Солнечный", "Тихий"]; + const nouns = ["Кот", "Пёс", "Лис", "Медведь", "Ёж"]; + + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + const number = Math.floor(Math.random() * 1000); // добавляем число для уникальности + + return `${adj}${noun}${number}`; +})(); + +/** + * Класс пользователя. + * Отвечает за работу с данными о пользователе. + */ +export default class UserModel { + /** + * Возвращает имя пользователя + * @returns {string} + */ + static getUsername() { + return username; // заглушка + } +} \ No newline at end of file diff --git a/src/scripts/views/index-header-view.js b/src/scripts/views/index-header-view.js index 0ed5052..e07abe1 100644 --- a/src/scripts/views/index-header-view.js +++ b/src/scripts/views/index-header-view.js @@ -1,4 +1,3 @@ -// watch-view.js import BaseView from "./base-view.js"; export default class IndexHeaderView extends BaseView { diff --git a/src/scripts/views/movie-modal-view.js b/src/scripts/views/movie-modal-view.js index 47eddcd..75acab3 100644 --- a/src/scripts/views/movie-modal-view.js +++ b/src/scripts/views/movie-modal-view.js @@ -87,10 +87,10 @@ export default class MovieModalView extends BaseView { } /** - * Обработчик кликов по кнопкам модалки + * Обработчик кликов * @param {MouseEvent} event */ - #handleButtons = (event) => { + #handleClicks = (event) => { const closeBtn = event.target.closest("[data-modal-close]"); if (closeBtn) return this.close(); @@ -103,14 +103,14 @@ export default class MovieModalView extends BaseView { /** Убирает события перед перерендером (BaseView) */ _detachEvents() { - this._$el.removeEventListener("click", this.#handleButtons); + this._$el.removeEventListener("click", this.#handleClicks); } /** Добавляет события после рендера (BaseView) */ _attachEvents() { - // Делегирование событий кликов на контейнер модалки - // Позволяет обрабатывать все кнопки внутри одной функции - this._$el.addEventListener("click", this.#handleButtons); + // Делегирование событий кликов на контейнер + // Позволяет обрабатывать все клики внутри одной функции + this._$el.addEventListener("click", this.#handleClicks); } } diff --git a/src/scripts/views/movie-watch-view.js b/src/scripts/views/movie-watch-view.js index 5425251..4aabcae 100644 --- a/src/scripts/views/movie-watch-view.js +++ b/src/scripts/views/movie-watch-view.js @@ -1,20 +1,100 @@ -// watch-view.js import BaseView from "../views/base-view.js"; +import { createMessageChannel } from "../adapters/broadcast-channel-adapter.js"; +import UserModel from "../models/user-model.js"; export default class MovieWatchView extends BaseView { - constructor(selector) { - super(selector); + #messageChannel + #currentUser + #messages = [] + + constructor(selector, initialData = {}) { + super(selector, initialData); + + // чат должен быть привязан к фильму + const channelName = `flickmate_channel_${this._data.id}`; + this.#messageChannel = createMessageChannel(channelName); + + this.#messageChannel.onMessage((messageData) => { + if (!messageData) return; + const message = JSON.parse(messageData); + this.#addChatMessage(message); + }); + + this.#currentUser = UserModel.getUsername(); + } + + sendMessage(messageText) { + if (!messageText) return; + const message = { sender: this.#currentUser, text: messageText }; + this.#messageChannel.send(JSON.stringify(message)); + this.#addChatMessage(message); + } + + #addChatMessage(message) { + this.#messages.push(message); + this.render(); + } + + #createMessageHTML({ sender, text }) { + const isMyMessage = sender === this.#currentUser; + return ` +
+ ${sender}:  + ${text} +
+ `; } _createInnerHTML() { - return ""; + return ` +
+
+ +
+
+

Чат

+
+ ${this.#messages.map(this.#createMessageHTML.bind(this)).join("")} +
+
+ + +
+
+
+ `; } - _attachEvents() { - return; + /** + * Обработчик кликов + * @param {MouseEvent} event + */ + #handleClicks = (event) => { + const sendBtn = event.target.closest(".watch-send-btn"); + if (sendBtn) { + event.preventDefault(); + const input = document.querySelector(".chat-input"); + this.sendMessage(input.value); + input.value = ""; + } } + /** Убирает события перед перерендером (BaseView) */ _detachEvents() { - return; + this._$el.removeEventListener("click", this.#handleClicks); + } + + /** Добавляет события после рендера (BaseView) */ + _attachEvents() { + // Делегирование событий на контейнер + // Позволяет обрабатывать события внутри одной функции + this._$el.addEventListener("click", this.#handleClicks); + } + + render() { + super.render(); + const chat = document.querySelector(".watch-chat"); + chat.scrollTop = chat.scrollHeight; + chat.querySelector(".chat-input").focus(); } } diff --git a/src/scripts/views/watch-header-view.js b/src/scripts/views/watch-header-view.js index 482f56d..c862492 100644 --- a/src/scripts/views/watch-header-view.js +++ b/src/scripts/views/watch-header-view.js @@ -1,4 +1,3 @@ -// watch-view.js import BaseView from "./base-view.js"; export default class WatchHeaderView extends BaseView { diff --git a/src/styles/common.css b/src/styles/common.css index 3b42311..cd8b879 100644 --- a/src/styles/common.css +++ b/src/styles/common.css @@ -69,7 +69,13 @@ a { text-decoration: none; } -/* header */ +.page-wrapper { + display: flex; + flex-direction: column; + width: 100vw; + min-height: 100vh; +} + .main-header { position: sticky; top: calc(var(--spacing) * -3); @@ -77,4 +83,9 @@ a { padding-block: calc(var(--spacing) * 6) calc(var(--spacing) * 2); padding-inline: calc(var(--spacing) * 6); box-shadow: 6px 10px 10px 0 color-mix(in srgb, var(--color-bg) 50%, transparent); +} + +.content-wrapper { + flex-grow: 1; + display: flex; } \ No newline at end of file diff --git a/src/styles/movie-watch.css b/src/styles/movie-watch.css new file mode 100644 index 0000000..b7ce02b --- /dev/null +++ b/src/styles/movie-watch.css @@ -0,0 +1,94 @@ +/* Контейнер всего экрана */ +.watch-container { + max-width: 1440px; + display: grid; + grid-template-columns: 5fr 2fr; + /* 2/3 плеер, 1/3 чат */ + gap: calc(var(--spacing) * 4); + padding-block: calc(var(--spacing) * 6); + padding-inline: calc(var(--spacing) * 6); + margin: 0 auto; + flex-grow: 1; +} + +/* Плеер */ +.watch-player { + display: flex; + align-items: center; + justify-content: center; +} + +.watch-player video { + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Чат */ +.watch-chat { + display: flex; + flex-direction: column; + overflow: hidden; + gap: calc(var(--spacing) * 3); +} + +/* Список сообщений */ +.watch-chat-messages { + flex-grow: 1; + overflow-y: auto; +} + +.watch-chat-message:first-child { + margin-block-start: 0; +} + +.watch-chat-message { + margin-block: calc(var(--spacing) * 3); + word-wrap: break-word; +} + +.watch-chat-sender { + font-weight: var(--font-weight-bold); + color: var(--color-secondary); +} + +.watch-chat-message-author { + color: var(--color-primary); +} + +/* Форма ввода */ +.watch-chat-form { + display: flex; +} + +.watch-chat-form input { + flex-grow: 1; + border: 1px solid var(--color-border); + background-color: transparent; + color: var(--color-base); + padding-inline: calc(var(--spacing) * 2); + padding-block: calc(var(--spacing) * 3); +} + +.watch-chat-form button { + cursor: pointer; + min-width: 45px; + font-weight: var(--font-weight-bold); + font-size: var(--text-h2); +} + +/* Адаптив: на маленьких экранах чат под плеером */ +@media (max-width: 768px) { + .watch-container { + grid-template-columns: 1fr; + grid-template-rows: 2fr 1fr; + } + + .watch-player { + height: 60vh; + } + + .watch-chat { + height: 40vh; + } +} \ No newline at end of file diff --git a/src/watch.html b/src/watch.html index 838c81d..1fd1257 100644 --- a/src/watch.html +++ b/src/watch.html @@ -10,6 +10,7 @@ + From 56d15d07d6f09dc3f7ddf059bf9116690bbaf6a6 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Tue, 21 Oct 2025 17:19:36 +0300 Subject: [PATCH 04/10] extracted player and chat --- src/index.html | 6 +- src/scripts/index.js | 33 ++++++-- src/scripts/views/base-view.js | 14 ++-- src/scripts/views/chat-view.js | 96 +++++++++++++++++++++++ src/scripts/views/index-header-view.js | 14 ---- src/scripts/views/movie-list-view.js | 17 ++-- src/scripts/views/movie-modal-view.js | 10 +-- src/scripts/views/movie-watch-view.js | 103 ++++--------------------- src/scripts/views/player-view.js | 8 ++ src/scripts/views/watch-header-view.js | 14 ---- src/scripts/watch.js | 32 ++++++-- src/styles/movie-watch.css | 12 +-- src/watch.html | 5 +- 13 files changed, 197 insertions(+), 167 deletions(-) create mode 100644 src/scripts/views/chat-view.js create mode 100644 src/scripts/views/player-view.js diff --git a/src/index.html b/src/index.html index 8c4f422..dfe1083 100644 --- a/src/index.html +++ b/src/index.html @@ -15,11 +15,7 @@ -
- -
-
- +
diff --git a/src/scripts/index.js b/src/scripts/index.js index 87cf8b9..5cf1410 100644 --- a/src/scripts/index.js +++ b/src/scripts/index.js @@ -1,3 +1,4 @@ +import BaseView from "./views/base-view"; import IndexHeaderView from "./views/index-header-view"; import MovieListView from "./views/movie-list-view"; import MovieModel from "./models/movie-model"; @@ -6,24 +7,40 @@ import MovieModel from "./models/movie-model"; * Класс главной страницы приложения. * Отвечает за инициализацию и рендер списка фильмов. */ -class IndexPage { +class IndexPage extends BaseView { /** @type {IndexHeaderView} Хэдер */ indexHeaderView /** @type {MovieListView} Список фильмов */ movieListView - constructor() { - // Создаём View хэдера - this.indexHeaderView = new IndexHeaderView("#page-header"); + #movies + + constructor(...args) { + super(...args); + // Получаем все фильмы из модели - const movies = MovieModel.getAll(); - // Создаём View для списка фильмов - this.movieListView = new MovieListView("#movie-list-container", movies); + this.#movies = MovieModel.getAll(); + } + + _createInnerHTML() { + return ` +
+ +
+
+ `; } /** Рендерит главную страницу */ render() { + super.render(); + + // Создаём View хэдера + this.indexHeaderView = new IndexHeaderView("#page-header"); this.indexHeaderView.render(); + + // Создаём View для списка фильмов + this.movieListView = new MovieListView("#movie-list-container", this.#movies); this.movieListView.render(); } } @@ -34,6 +51,6 @@ class IndexPage { * - window.indexPage даёт доступ к корневому объекту в консоли браузера (для отладки) */ document.addEventListener("DOMContentLoaded", () => { - window.indexPage = new IndexPage(); + window.indexPage = new IndexPage("#root"); window.indexPage.render(); }); \ No newline at end of file diff --git a/src/scripts/views/base-view.js b/src/scripts/views/base-view.js index a872a30..02f8e31 100644 --- a/src/scripts/views/base-view.js +++ b/src/scripts/views/base-view.js @@ -8,7 +8,6 @@ export default class BaseView { /** @type {HTMLElement} Элемент контейнера, куда рендерится контент */ _$el - /** @type {any} Данные для отображения в компоненте */ _data @@ -18,6 +17,11 @@ export default class BaseView { */ constructor(selector, initialData = {}) { this._$el = document.querySelector(selector); + + if (!this._$el) { + throw new Error("Element is not found"); + } + this._data = initialData; } @@ -32,18 +36,18 @@ export default class BaseView { /** * Метод для удаления событий перед перерендером. - * Должен быть реализован в наследнике. + * Может быть реализован в наследнике. */ _detachEvents() { - throw new Error("Method _detachEvents() must be implemented"); + return; } /** * Метод для установки событий после рендера. - * Должен быть реализован в наследнике. + * Может быть реализован в наследнике. */ _attachEvents() { - throw new Error("Method _attachEvents() must be implemented"); + return; } /** diff --git a/src/scripts/views/chat-view.js b/src/scripts/views/chat-view.js new file mode 100644 index 0000000..19bec10 --- /dev/null +++ b/src/scripts/views/chat-view.js @@ -0,0 +1,96 @@ +import BaseView from "./base-view.js"; +import { createMessageChannel } from "../adapters/broadcast-channel-adapter.js"; +import UserModel from "../models/user-model.js"; + +export default class ChatView extends BaseView { + #messageChannel + #currentUser + #messages = [] + + constructor(...args) { + super(...args); + + // чат должен быть привязан к фильму + const channelName = `flickmate_channel_${this._data.id}`; + this.#messageChannel = createMessageChannel(channelName); + + this.#messageChannel.onMessage((messageData) => { + if (!messageData) return; + const message = JSON.parse(messageData); + this.#addChatMessage(message); + }); + + this.#currentUser = UserModel.getUsername(); + } + + #createMessageHTML({ sender, text }) { + const isMyMessage = sender === this.#currentUser; + return ` +
+ ${sender}:  + ${text} +
+ `; + } + + sendMessage(messageText) { + if (!messageText) return; + const message = { sender: this.#currentUser, text: messageText }; + this.#messageChannel.send(JSON.stringify(message)); + this.#addChatMessage(message); + } + + #addChatMessage(message) { + this.#messages.push(message); + this.render(); + } + + /** Метод для рендера HTML (обязательный для BaseView) */ + _createInnerHTML() { + return ` +
+

Чат

+
+ ${this.#messages.map(this.#createMessageHTML.bind(this)).join("")} +
+
+ + +
+
+ `; + } + + /** + * Обработчик кликов + * @param {MouseEvent} event + */ + #handleClicks = (event) => { + const sendBtn = event.target.closest(".watch-send-btn"); + if (sendBtn) { + event.preventDefault(); + const input = document.querySelector(".chat-input"); + this.sendMessage(input.value); + input.value = ""; + } + } + + /** Убирает события перед перерендером (BaseView) */ + _detachEvents() { + this._$el.removeEventListener("click", this.#handleClicks); + } + + /** Добавляет события после рендера (BaseView) */ + _attachEvents() { + // Делегирование событий на контейнер + // Позволяет обрабатывать события внутри одной функции + this._$el.addEventListener("click", this.#handleClicks); + } + + render() { + super.render(); + const chat = document.querySelector("#watch-chat"); + chat.scrollTop = chat.scrollHeight; + chat.querySelector(".chat-input").focus(); + } +} diff --git a/src/scripts/views/index-header-view.js b/src/scripts/views/index-header-view.js index e07abe1..a9324d3 100644 --- a/src/scripts/views/index-header-view.js +++ b/src/scripts/views/index-header-view.js @@ -1,22 +1,8 @@ import BaseView from "./base-view.js"; export default class IndexHeaderView extends BaseView { - constructor(selector) { - super(selector); - } - /** Метод для рендера HTML (обязательный для BaseView) */ _createInnerHTML() { return "

Каталог фильмов

"; } - - /** Убирает события перед перерендером (BaseView) */ - _detachEvents() { - return; - } - - /** Добавляет события после рендера (BaseView) */ - _attachEvents() { - return; - } } diff --git a/src/scripts/views/movie-list-view.js b/src/scripts/views/movie-list-view.js index 1d53438..386aeff 100644 --- a/src/scripts/views/movie-list-view.js +++ b/src/scripts/views/movie-list-view.js @@ -12,15 +12,6 @@ export default class MovieListView extends BaseView { /** @type {MovieModalView} Экземпляр модального окна */ #movieModal; - /** - * @param {string} selector - CSS-селектор контейнера списка фильмов - * @param {Array} initialData - Массив объектов фильмов - */ - constructor(selector, initialData = []) { - super(selector, initialData); - this.#movieModal = createMovieModal(); - } - /** * Генерирует HTML для одной карточки фильма * @param {Object} movie - Объект фильма @@ -51,6 +42,7 @@ export default class MovieListView extends BaseView { return `
${this._data.map(this.#createItem.bind(this)).join("")} +
`; } @@ -89,4 +81,11 @@ export default class MovieListView extends BaseView { _attachEvents() { this._$el.addEventListener("click", this.#handleModal); } + + render() { + super.render(); + + // Создаём View модалки детального просмотра фильма + this.#movieModal = createMovieModal(); + } } \ No newline at end of file diff --git a/src/scripts/views/movie-modal-view.js b/src/scripts/views/movie-modal-view.js index 75acab3..c2d68f6 100644 --- a/src/scripts/views/movie-modal-view.js +++ b/src/scripts/views/movie-modal-view.js @@ -6,14 +6,6 @@ import BaseView from "./base-view"; * Наследуется от BaseView. */ export default class MovieModalView extends BaseView { - /** - * @param {string} selector - CSS-селектор контейнера модалки - * @param {Object} initialData - Начальные данные для модалки - */ - constructor(selector, initialData = {}) { - super(selector, initialData); - } - /** * Открывает модалку с переданными данными фильма * @param {Object} movie - Объект фильма @@ -91,6 +83,8 @@ export default class MovieModalView extends BaseView { * @param {MouseEvent} event */ #handleClicks = (event) => { + event.stopPropagation(); + const closeBtn = event.target.closest("[data-modal-close]"); if (closeBtn) return this.close(); diff --git a/src/scripts/views/movie-watch-view.js b/src/scripts/views/movie-watch-view.js index 4aabcae..86854ec 100644 --- a/src/scripts/views/movie-watch-view.js +++ b/src/scripts/views/movie-watch-view.js @@ -1,100 +1,31 @@ -import BaseView from "../views/base-view.js"; -import { createMessageChannel } from "../adapters/broadcast-channel-adapter.js"; -import UserModel from "../models/user-model.js"; +import BaseView from "./base-view.js"; +import PlayerView from "./player-view.js"; +import ChatView from "./chat-view.js"; export default class MovieWatchView extends BaseView { - #messageChannel - #currentUser - #messages = [] - - constructor(selector, initialData = {}) { - super(selector, initialData); - - // чат должен быть привязан к фильму - const channelName = `flickmate_channel_${this._data.id}`; - this.#messageChannel = createMessageChannel(channelName); - - this.#messageChannel.onMessage((messageData) => { - if (!messageData) return; - const message = JSON.parse(messageData); - this.#addChatMessage(message); - }); - - this.#currentUser = UserModel.getUsername(); - } - - sendMessage(messageText) { - if (!messageText) return; - const message = { sender: this.#currentUser, text: messageText }; - this.#messageChannel.send(JSON.stringify(message)); - this.#addChatMessage(message); - } - - #addChatMessage(message) { - this.#messages.push(message); - this.render(); - } - - #createMessageHTML({ sender, text }) { - const isMyMessage = sender === this.#currentUser; - return ` -
- ${sender}:  - ${text} -
- `; - } + /** @type {PlayerView} Плеер */ + playerView + /** @type {ChatView} Чат */ + chatView _createInnerHTML() { return `
-
- -
-
-

Чат

-
- ${this.#messages.map(this.#createMessageHTML.bind(this)).join("")} -
-
- - -
-
+
+
`; } - /** - * Обработчик кликов - * @param {MouseEvent} event - */ - #handleClicks = (event) => { - const sendBtn = event.target.closest(".watch-send-btn"); - if (sendBtn) { - event.preventDefault(); - const input = document.querySelector(".chat-input"); - this.sendMessage(input.value); - input.value = ""; - } - } - - /** Убирает события перед перерендером (BaseView) */ - _detachEvents() { - this._$el.removeEventListener("click", this.#handleClicks); - } - - /** Добавляет события после рендера (BaseView) */ - _attachEvents() { - // Делегирование событий на контейнер - // Позволяет обрабатывать события внутри одной функции - this._$el.addEventListener("click", this.#handleClicks); - } - render() { super.render(); - const chat = document.querySelector(".watch-chat"); - chat.scrollTop = chat.scrollHeight; - chat.querySelector(".chat-input").focus(); + + // Создаём View плеера + this.playerView = new PlayerView("#watch-player", this._data); + this.playerView.render(); + + // Создаём View чата + this.chatView = new ChatView("#watch-chat", this._data); + this.chatView.render(); } } diff --git a/src/scripts/views/player-view.js b/src/scripts/views/player-view.js new file mode 100644 index 0000000..0941497 --- /dev/null +++ b/src/scripts/views/player-view.js @@ -0,0 +1,8 @@ +import BaseView from "./base-view.js"; + +export default class PlayerView extends BaseView { + /** Метод для рендера HTML (обязательный для BaseView) */ + _createInnerHTML() { + return ``; + } +} diff --git a/src/scripts/views/watch-header-view.js b/src/scripts/views/watch-header-view.js index c862492..9a96fc3 100644 --- a/src/scripts/views/watch-header-view.js +++ b/src/scripts/views/watch-header-view.js @@ -1,22 +1,8 @@ import BaseView from "./base-view.js"; export default class WatchHeaderView extends BaseView { - constructor(selector, initialData = {}) { - super(selector, initialData); - } - /** Метод для рендера HTML (обязательный для BaseView) */ _createInnerHTML() { return `

 ${this._data.title}

`; } - - /** Убирает события перед перерендером (BaseView) */ - _detachEvents() { - return; - } - - /** Добавляет события после рендера (BaseView) */ - _attachEvents() { - return; - } } diff --git a/src/scripts/watch.js b/src/scripts/watch.js index 98bc5ff..a15d753 100644 --- a/src/scripts/watch.js +++ b/src/scripts/watch.js @@ -1,3 +1,4 @@ +import BaseView from "./views/base-view"; import WatchHeaderView from "./views/watch-header-view"; import MovieWatchView from "./views/movie-watch-view.js"; import MovieModel from "./models/movie-model.js"; @@ -6,29 +7,44 @@ import MovieModel from "./models/movie-model.js"; * Класс страницы совместного просмотра фильма. * Отвечает за инициализацию и рендер экрана совместного просмотра. */ -class WatchPage { +class WatchPage extends BaseView { /** @type {WatchHeaderView} Хэдер */ watchHeaderView /** @type {MovieWatchView} Экран совместного просмотра */ movieWatchView - constructor() { + #movie + + constructor(...args) { + super(...args); + // Получаем id фильма из ?movie_id const params = new URLSearchParams(window.location.search); const movieId = Number(params.get("movie_id")); // Получаем информацию о фильме из модели - const movie = MovieModel.getById(movieId); + this.#movie = MovieModel.getById(movieId); + } - // Создаём View хэдера - this.watchHeaderView = new WatchHeaderView("#page-header", movie); - // Создаём View совместного просмотра - this.movieWatchView = new MovieWatchView("#movie-watch-container", movie); + _createInnerHTML() { + return ` +
+ +
+
+ `; } /** Рендерит страницу совместного просмотра */ render() { + super.render(); + + // Создаём View хэдера + this.watchHeaderView = new WatchHeaderView("#page-header", this.#movie); this.watchHeaderView.render(); + + // Создаём View совместного просмотра + this.movieWatchView = new MovieWatchView("#movie-watch-container", this.#movie); this.movieWatchView.render(); } } @@ -39,6 +55,6 @@ class WatchPage { * - window.watchPage даёт доступ к корневому объекту в консоли браузера (для отладки) */ document.addEventListener("DOMContentLoaded", () => { - window.watchPage = new WatchPage(); + window.watchPage = new WatchPage("#root"); window.watchPage.render(); }); \ No newline at end of file diff --git a/src/styles/movie-watch.css b/src/styles/movie-watch.css index b7ce02b..975894a 100644 --- a/src/styles/movie-watch.css +++ b/src/styles/movie-watch.css @@ -3,7 +3,6 @@ max-width: 1440px; display: grid; grid-template-columns: 5fr 2fr; - /* 2/3 плеер, 1/3 чат */ gap: calc(var(--spacing) * 4); padding-block: calc(var(--spacing) * 6); padding-inline: calc(var(--spacing) * 6); @@ -12,24 +11,25 @@ } /* Плеер */ -.watch-player { +#watch-player { display: flex; align-items: center; justify-content: center; } -.watch-player video { +#watch-player video { width: 100%; height: 100%; object-fit: contain; } /* Чат */ -.watch-chat { +#watch-chat > div { display: flex; flex-direction: column; overflow: hidden; gap: calc(var(--spacing) * 3); + height: 100%; } /* Список сообщений */ @@ -84,11 +84,11 @@ grid-template-rows: 2fr 1fr; } - .watch-player { + #watch-player { height: 60vh; } - .watch-chat { + #watch-chat > div { height: 40vh; } } \ No newline at end of file diff --git a/src/watch.html b/src/watch.html index 1fd1257..8bbe8d2 100644 --- a/src/watch.html +++ b/src/watch.html @@ -14,10 +14,7 @@ -
- -
-
+
From 9ea9fc26e426719943ceae05f6596fb41ab556c8 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Tue, 21 Oct 2025 20:32:50 +0300 Subject: [PATCH 05/10] implemented message history --- src/scripts/adapters/local-storage-adapter.js | 32 ++++++++++++++ src/scripts/index.js | 1 + src/scripts/views/chat-view.js | 44 ++++++++++++++----- src/scripts/watch.js | 1 + 4 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 src/scripts/adapters/local-storage-adapter.js diff --git a/src/scripts/adapters/local-storage-adapter.js b/src/scripts/adapters/local-storage-adapter.js new file mode 100644 index 0000000..f4ff2f0 --- /dev/null +++ b/src/scripts/adapters/local-storage-adapter.js @@ -0,0 +1,32 @@ +export default class LocalStorageAdapter { + #key + + constructor(storageKey) { + this.#key = storageKey; + } + + get value() { + try { + const data = localStorage.getItem(this.#key); + return JSON.parse(data); + } catch (err) { + console.error("Ошибка чтения из localStorage:", err); + } + } + + set value(data) { + if (data === null) { + localStorage.removeItem(this.#key); + return; + } + + try { + localStorage.setItem(this.#key, JSON.stringify(data)); + } catch (err) { + console.error("Ошибка сохранения в localStorage:", err); + } + } +} + +/** Фабрика для создания локального хранилища */ +export const createLocalStorage = (storageKey) => new LocalStorageAdapter(storageKey); diff --git a/src/scripts/index.js b/src/scripts/index.js index 5cf1410..6ca0ea4 100644 --- a/src/scripts/index.js +++ b/src/scripts/index.js @@ -22,6 +22,7 @@ class IndexPage extends BaseView { this.#movies = MovieModel.getAll(); } + /** Метод для рендера HTML (обязательный для BaseView) */ _createInnerHTML() { return `
diff --git a/src/scripts/views/chat-view.js b/src/scripts/views/chat-view.js index 19bec10..69cffa3 100644 --- a/src/scripts/views/chat-view.js +++ b/src/scripts/views/chat-view.js @@ -1,16 +1,19 @@ import BaseView from "./base-view.js"; -import { createMessageChannel } from "../adapters/broadcast-channel-adapter.js"; import UserModel from "../models/user-model.js"; +import { createMessageChannel } from "../adapters/broadcast-channel-adapter.js"; +import { createLocalStorage } from "../adapters/local-storage-adapter.js"; export default class ChatView extends BaseView { #messageChannel + #messageStorage #currentUser - #messages = [] + + #messages constructor(...args) { super(...args); - // чат должен быть привязан к фильму + // чат привязан к фильму const channelName = `flickmate_channel_${this._data.id}`; this.#messageChannel = createMessageChannel(channelName); @@ -20,23 +23,35 @@ export default class ChatView extends BaseView { this.#addChatMessage(message); }); + // история сообщений чата привязана к фильму + const storageKey = `flickmate_chat_${this._data.id}`; + this.#messageStorage = createLocalStorage(storageKey); + this.#messages = this.#messageStorage.value ?? []; + this.#currentUser = UserModel.getUsername(); } + #isMyMessage(sender) { + return sender === this.#currentUser; + } + #createMessageHTML({ sender, text }) { - const isMyMessage = sender === this.#currentUser; + const messageClass = `watch-chat-sender${this.#isMyMessage(sender) ? " watch-chat-message-author" : ""}`; return `
- ${sender}:  + ${sender}:  ${text}
`; } - sendMessage(messageText) { + #sendMessage(messageText) { if (!messageText) return; + const message = { sender: this.#currentUser, text: messageText }; this.#messageChannel.send(JSON.stringify(message)); + this.#messageStorage.value = [...this.#messages, message]; + this.#addChatMessage(message); } @@ -69,8 +84,8 @@ export default class ChatView extends BaseView { const sendBtn = event.target.closest(".watch-send-btn"); if (sendBtn) { event.preventDefault(); - const input = document.querySelector(".chat-input"); - this.sendMessage(input.value); + const input = this._$el.querySelector(".chat-input"); + this.#sendMessage(input.value); input.value = ""; } } @@ -87,10 +102,17 @@ export default class ChatView extends BaseView { this._$el.addEventListener("click", this.#handleClicks); } + #scrollToBottom() { + this._$el.scrollTop = this._$el.scrollHeight; + } + + #focusOnInput() { + this._$el.querySelector(".chat-input").focus(); + } + render() { super.render(); - const chat = document.querySelector("#watch-chat"); - chat.scrollTop = chat.scrollHeight; - chat.querySelector(".chat-input").focus(); + this.#scrollToBottom(); + this.#focusOnInput(); } } diff --git a/src/scripts/watch.js b/src/scripts/watch.js index a15d753..5f711d1 100644 --- a/src/scripts/watch.js +++ b/src/scripts/watch.js @@ -26,6 +26,7 @@ class WatchPage extends BaseView { this.#movie = MovieModel.getById(movieId); } + /** Метод для рендера HTML (обязательный для BaseView) */ _createInnerHTML() { return `
From 76f9769fc1640691492b4a2dc1129be8ca088162 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Tue, 21 Oct 2025 21:51:15 +0300 Subject: [PATCH 06/10] added responsive --- src/scripts/views/chat-view.js | 3 ++- src/styles/common.css | 7 ++++++- src/styles/movie-modal.css | 4 ++-- src/styles/movie-watch.css | 38 ++++++++++++++++++++++++---------- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/scripts/views/chat-view.js b/src/scripts/views/chat-view.js index 69cffa3..2586716 100644 --- a/src/scripts/views/chat-view.js +++ b/src/scripts/views/chat-view.js @@ -103,7 +103,8 @@ export default class ChatView extends BaseView { } #scrollToBottom() { - this._$el.scrollTop = this._$el.scrollHeight; + const messagesContainer = this._$el.querySelector("#chat-messages"); + messagesContainer.scrollTop = messagesContainer.scrollHeight; } #focusOnInput() { diff --git a/src/styles/common.css b/src/styles/common.css index cd8b879..5696657 100644 --- a/src/styles/common.css +++ b/src/styles/common.css @@ -19,6 +19,9 @@ --text-h3: clamp(14px, 2.5vw, 17px); --text-base: clamp(12px, 2vw, 14px); --text-button: clamp(13px, 2vw, 15px); + + --main-header-height: 70px; + --box-shadow: 6px 10px 10px 0 color-mix(in srgb, var(--color-bg) 50%, transparent); } *, @@ -82,7 +85,9 @@ a { background-color: var(--color-bg); padding-block: calc(var(--spacing) * 6) calc(var(--spacing) * 2); padding-inline: calc(var(--spacing) * 6); - box-shadow: 6px 10px 10px 0 color-mix(in srgb, var(--color-bg) 50%, transparent); + box-shadow: var(--box-shadow); + z-index: 1; + height: var(--main-header-height); } .content-wrapper { diff --git a/src/styles/movie-modal.css b/src/styles/movie-modal.css index 0c5e828..6357638 100644 --- a/src/styles/movie-modal.css +++ b/src/styles/movie-modal.css @@ -7,7 +7,7 @@ dialog { width: 90%; background-color: var(--color-bg); color: var(--color-base); - box-shadow: 6px 10px 10px 0 color-mix(in srgb, var(--color-bg) 50%, transparent); + box-shadow: var(--box-shadow); } dialog::backdrop { @@ -114,7 +114,7 @@ dialog::backdrop { position: relative; width: 100vw; background-color: var(--color-bg); - box-shadow: 6px 10px 10px 0 color-mix(in srgb, var(--color-bg) 50%, transparent); + box-shadow: var(--box-shadow); } .movie-modal-body { diff --git a/src/styles/movie-watch.css b/src/styles/movie-watch.css index 975894a..b332ab2 100644 --- a/src/styles/movie-watch.css +++ b/src/styles/movie-watch.css @@ -1,13 +1,12 @@ /* Контейнер всего экрана */ .watch-container { max-width: 1440px; - display: grid; - grid-template-columns: 5fr 2fr; + display: flex; gap: calc(var(--spacing) * 4); padding-block: calc(var(--spacing) * 6); padding-inline: calc(var(--spacing) * 6); - margin: 0 auto; flex-grow: 1; + height: calc(100vh - var(--main-header-height)); } /* Плеер */ @@ -15,6 +14,7 @@ display: flex; align-items: center; justify-content: center; + flex-grow: 1; } #watch-player video { @@ -24,18 +24,28 @@ } /* Чат */ -#watch-chat > div { +#watch-chat { + width: 300px; +} + +#watch-chat>div { display: flex; flex-direction: column; overflow: hidden; - gap: calc(var(--spacing) * 3); height: 100%; } +#watch-chat h2 { + position: relative; + box-shadow: var(--box-shadow); + padding-block-end: calc(var(--spacing) * 1); +} + /* Список сообщений */ .watch-chat-messages { flex-grow: 1; overflow-y: auto; + padding-block-start: calc(var(--spacing) * 2); } .watch-chat-message:first-child { @@ -77,18 +87,24 @@ font-size: var(--text-h2); } -/* Адаптив: на маленьких экранах чат под плеером */ -@media (max-width: 768px) { +@media (max-width: 1024px) { .watch-container { - grid-template-columns: 1fr; - grid-template-rows: 2fr 1fr; + flex-direction: column; + padding: 0; } #watch-player { + height: 40vh; + } + + #watch-chat { + width: 100%; height: 60vh; + padding-inline: calc(var(--spacing) * 3); + padding-block-end: calc(var(--spacing) * 3); } - #watch-chat > div { - height: 40vh; + #watch-chat h2 { + display: none; } } \ No newline at end of file From 91b8f8c284d41c1b61604e983bcc40579928c737 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Wed, 22 Oct 2025 01:39:10 +0300 Subject: [PATCH 07/10] added movies --- src/data/movies.js | 60 +++++++++------------------ src/scripts/views/movie-list-view.js | 4 +- src/scripts/views/movie-modal-view.js | 2 +- src/scripts/views/player-view.js | 4 +- src/scripts/watch.js | 5 ++- 5 files changed, 28 insertions(+), 47 deletions(-) diff --git a/src/data/movies.js b/src/data/movies.js index d1f6776..9512b55 100644 --- a/src/data/movies.js +++ b/src/data/movies.js @@ -1,24 +1,22 @@ export default [ { - "id": 1, + "id": "-Cs3GyzRB2k?si=gON_3Ryi-msckxeM", "title": "Аватар", "subtitle": "Avatar, 2009", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2009" } ] }, { - "id": 2, + "id": "wGh3_6vXKFE?si=XSoFxwrRFpoT6IFt", "title": "Интерстеллар", "subtitle": "Interstellar, 2014", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2014" }, { name: "Жанр", value: "фантастика, драма, приключения" }, @@ -30,217 +28,199 @@ export default [ ] }, { - "id": 3, + "id": "qlrpeYdm9Ec?si=qN7flrk3BTzALy-K", "title": "Начало", "subtitle": "Inception, 2010", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2010" } ] }, { - "id": 4, + "id": "BG-WsYJScfY?si=0tri6oDhh1EzlOvK", "title": "Мстители: Война бесконечности", "subtitle": "Avengers: Infinity War, 2018", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2018" } ] }, { - "id": 5, + "id": "tVZw7vQGR30?si=t5DD3I_QbtOs1O7Y", "title": "Матрица", "subtitle": "The Matrix, 1999", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "1999" } ] }, { - "id": 6, + "id": "JNeIqSao7JM?si=DvPJ8-h00Lzps392", "title": "Мстители: Финал", "subtitle": "Avengers: Endgame, 2019", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2019" } ] }, { - "id": 7, + "id": "BGUemDhaxZg?si=V1iRWX67Il-l507M", "title": "Дюна", "subtitle": "Dune: Part One, 2021", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2021" } ] }, { - "id": 8, + "id": "CBrM51C97e0?si=UJtI3WbDfQLMVt2R", "title": "Пятый элемент", "subtitle": "The Fifth Element, 1997", "details": "Франция • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "1997" } ] }, { - "id": 9, + "id": "zpwHYFqjDA0?si=33lAZRRLlN6_dfcf", "title": "Стражи Галактики", "subtitle": "Guardians of the Galaxy, 2014", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2014" } ] }, { - "id": 10, + "id": "UDA6Kd6uYqs?si=tzmx7VB95msmsAMF", "title": "Кракен", "subtitle": "2025", "details": "Россия • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2025" } ] }, { - "id": 11, + "id": "BcEZ6ox6vzI?si=Ty_wC_r33FBmgZDj", "title": "Темный рыцарь", "subtitle": "The Dark Knight, 2008", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2008" } ] }, { - "id": 12, + "id": "XsoL3c0FdQg?si=MRt3hvA29Mz6UCPB", "title": "Доктор Стрэндж", "subtitle": "Doctor Strange, 2016", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2016" } ] }, { - "id": 13, + "id": "ET_TnwrzdJ4?si=gb0ljbRGGoYJkFe9", "title": "Мстители", "subtitle": "The Avengers, 2012", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2012" } ] }, { - "id": 14, + "id": "l0gIlQOlHnI?si=8OGnj_h6bgMvBbpr", "title": "Терминатор 2: Судный день", "subtitle": "Terminator 2: Judgment Day, 1991", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "1991" } ] }, { - "id": 15, + "id": "15q-KZtkNMo?si=0ZnA1nu5Z4zHhK0S", "title": "Железный человек", "subtitle": "Iron Man, 2008", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2008" } ] }, { - "id": 16, + "id": "6NoZyEOttBw?si=iRx_OF4X7PK9UNgw", "title": "Назад в будущее", "subtitle": "Back to the Future, 1985", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "1985" } ] }, { - "id": 17, + "id": "P3r7RRJszC0?si=QBv1pazfeTJz_jEC", "title": "Марсианин", "subtitle": "The Martian, 2015", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2015" } ] }, { - "id": 18, + "id": "mUN11OqyJPE?si=n0-I0IF4fRFN_Aor", "title": "Кибердеревня", "subtitle": "2023", "details": "Россия • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2023" } ] }, { - "id": 19, + "id": "1EcZ9_-h710?si=NEBAFCPfMoaSmMpk", "title": "Главный герой", "subtitle": "Free Guy, 2021", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2021" } ] }, { - "id": 20, + "id": "UQ4DujiiS1o?si=CFX7u1c-i67c7LY5", "title": "Первому игроку приготовиться", "subtitle": "Ready Player One, 2018", "details": "США • фантастика", "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", - "link": "#", "metadata": [ { name: "Год производства", value: "2018" } ] diff --git a/src/scripts/views/movie-list-view.js b/src/scripts/views/movie-list-view.js index 386aeff..08e5a9e 100644 --- a/src/scripts/views/movie-list-view.js +++ b/src/scripts/views/movie-list-view.js @@ -20,7 +20,7 @@ export default class MovieListView extends BaseView { #createItem({ id, title, subtitle, img, details }) { return `
- +
${title}
@@ -66,7 +66,7 @@ export default class MovieListView extends BaseView { // даже если кликнули по вложенному тегу внутри карточки const movieItem = event.target.closest("[data-modal-open]"); if (movieItem) { - const movieId = +movieItem.dataset.id; + const movieId = movieItem.dataset.id; const movie = MovieModel.getById(movieId); this.#movieModal.open(movie); } diff --git a/src/scripts/views/movie-modal-view.js b/src/scripts/views/movie-modal-view.js index c2d68f6..dc81f4e 100644 --- a/src/scripts/views/movie-modal-view.js +++ b/src/scripts/views/movie-modal-view.js @@ -60,7 +60,7 @@ export default class MovieModalView extends BaseView {
- Смотреть вместе → + Смотреть вместе →
diff --git a/src/scripts/views/player-view.js b/src/scripts/views/player-view.js index 0941497..c564200 100644 --- a/src/scripts/views/player-view.js +++ b/src/scripts/views/player-view.js @@ -3,6 +3,6 @@ import BaseView from "./base-view.js"; export default class PlayerView extends BaseView { /** Метод для рендера HTML (обязательный для BaseView) */ _createInnerHTML() { - return ``; + return ``; } -} +} \ No newline at end of file diff --git a/src/scripts/watch.js b/src/scripts/watch.js index 5f711d1..40caed4 100644 --- a/src/scripts/watch.js +++ b/src/scripts/watch.js @@ -20,10 +20,11 @@ class WatchPage extends BaseView { // Получаем id фильма из ?movie_id const params = new URLSearchParams(window.location.search); - const movieId = Number(params.get("movie_id")); + const movieId = params.get("movie_id"); + const decodedMovieId = decodeURIComponent(movieId); // Получаем информацию о фильме из модели - this.#movie = MovieModel.getById(movieId); + this.#movie = MovieModel.getById(decodedMovieId); } /** Метод для рендера HTML (обязательный для BaseView) */ From ee821db5384ab73c2548c53830bf5eed70e483a6 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Wed, 22 Oct 2025 01:46:00 +0300 Subject: [PATCH 08/10] handled errors --- .../adapters/broadcast-channel-adapter.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/scripts/adapters/broadcast-channel-adapter.js b/src/scripts/adapters/broadcast-channel-adapter.js index f469a58..4857888 100644 --- a/src/scripts/adapters/broadcast-channel-adapter.js +++ b/src/scripts/adapters/broadcast-channel-adapter.js @@ -2,7 +2,12 @@ export default class BroadcastChannelAdapter { #channel constructor(channelName) { - this.#channel = new BroadcastChannel(channelName); + try { + this.#channel = new BroadcastChannel(channelName); + } catch (err) { + console.error("Ошибка создания Broadcast Channel:", err); + } + } /** @@ -10,7 +15,11 @@ export default class BroadcastChannelAdapter { * @param {any} message */ send(message) { - this.#channel.postMessage(message); + try { + this.#channel.postMessage(message); + } catch (err) { + console.error("Ошибка отправки сообщения:", err); + } } /** @@ -18,7 +27,11 @@ export default class BroadcastChannelAdapter { * @param {function(any):void} callback */ onMessage(callback) { - this.#channel.onmessage = (event) => callback(event.data); + try { + this.#channel.onmessage = (event) => callback(event.data); + } catch (err) { + console.error("Ошибка подписки на новые сообщения:", err); + } } } From 8ec71f2ce2aa55ff578fb46dcaaebf2933a4c612 Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Wed, 22 Oct 2025 14:26:39 +0300 Subject: [PATCH 09/10] handled edge cases --- src/scripts/views/chat-view.js | 29 +++++- src/scripts/views/index-header-view.js | 2 +- src/scripts/views/player-view.js | 15 +++- src/scripts/views/watch-header-view.js | 2 +- src/styles/chat.css | 120 +++++++++++++++++++++++++ src/styles/movie-watch.css | 78 ---------------- src/styles/player.css | 12 +++ src/watch.html | 2 + 8 files changed, 175 insertions(+), 85 deletions(-) create mode 100644 src/styles/chat.css create mode 100644 src/styles/player.css diff --git a/src/scripts/views/chat-view.js b/src/scripts/views/chat-view.js index 2586716..7691540 100644 --- a/src/scripts/views/chat-view.js +++ b/src/scripts/views/chat-view.js @@ -60,14 +60,33 @@ export default class ChatView extends BaseView { this.render(); } + #createEmptyPlaceholder() { + return ` +
+ +

Тут пока тихо...

+
+ `; + } + + #createList() { + if (!this.#messages?.length) { + return this.#createEmptyPlaceholder(); + } + + return ` +
+ ${this.#messages.map(this.#createMessageHTML.bind(this)).join("")} +
+ ` + } + /** Метод для рендера HTML (обязательный для BaseView) */ _createInnerHTML() { return `

Чат

-
- ${this.#messages.map(this.#createMessageHTML.bind(this)).join("")} -
+ ${this.#createList()}
@@ -104,7 +123,9 @@ export default class ChatView extends BaseView { #scrollToBottom() { const messagesContainer = this._$el.querySelector("#chat-messages"); - messagesContainer.scrollTop = messagesContainer.scrollHeight; + if (messagesContainer) { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } } #focusOnInput() { diff --git a/src/scripts/views/index-header-view.js b/src/scripts/views/index-header-view.js index a9324d3..a7e4fd4 100644 --- a/src/scripts/views/index-header-view.js +++ b/src/scripts/views/index-header-view.js @@ -3,6 +3,6 @@ import BaseView from "./base-view.js"; export default class IndexHeaderView extends BaseView { /** Метод для рендера HTML (обязательный для BaseView) */ _createInnerHTML() { - return "

Каталог фильмов

"; + return `

Каталог фильмов

`; } } diff --git a/src/scripts/views/player-view.js b/src/scripts/views/player-view.js index c564200..a5606f1 100644 --- a/src/scripts/views/player-view.js +++ b/src/scripts/views/player-view.js @@ -3,6 +3,19 @@ import BaseView from "./base-view.js"; export default class PlayerView extends BaseView { /** Метод для рендера HTML (обязательный для BaseView) */ _createInnerHTML() { - return ``; + return ` +
+ +
+ `; } } \ No newline at end of file diff --git a/src/scripts/views/watch-header-view.js b/src/scripts/views/watch-header-view.js index 9a96fc3..d4b553a 100644 --- a/src/scripts/views/watch-header-view.js +++ b/src/scripts/views/watch-header-view.js @@ -3,6 +3,6 @@ import BaseView from "./base-view.js"; export default class WatchHeaderView extends BaseView { /** Метод для рендера HTML (обязательный для BaseView) */ _createInnerHTML() { - return `

 ${this._data.title}

`; + return `

 ${this._data.title}

`; } } diff --git a/src/styles/chat.css b/src/styles/chat.css new file mode 100644 index 0000000..f347af8 --- /dev/null +++ b/src/styles/chat.css @@ -0,0 +1,120 @@ +/* Чат */ +#watch-chat { + width: 300px; +} + +#watch-chat>div { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; +} + +#watch-chat h2 { + position: relative; + box-shadow: var(--box-shadow); + padding-block-end: calc(var(--spacing) * 1); +} + +/* Список сообщений */ +.watch-chat-messages { + flex-grow: 1; + overflow-y: auto; + padding-block-start: calc(var(--spacing) * 2); +} + +.watch-chat-message:first-child { + margin-block-start: 0; +} + +.watch-chat-message { + margin-block: calc(var(--spacing) * 3); + word-wrap: break-word; +} + +.watch-chat-sender { + font-weight: var(--font-weight-bold); + color: var(--color-secondary); +} + +.watch-chat-message-author { + color: var(--color-primary); +} + +/* Пустой список */ +.chat-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + +.chat-empty-state .popcorn { + font-size: 4rem; + cursor: pointer; + user-select: none; + transition: transform 0.1s ease; + background-color: transparent; + border: none; +} + +.chat-empty-state .popcorn, +.chat-empty-state>p { + opacity: 0.7; +} + +.chat-empty-state .popcorn:hover, +.chat-empty-state .popcorn:focus, +.chat-empty-state .popcorn:active { + opacity: 1; +} + +.chat-empty-state .popcorn:active { + animation: popcorn-shake 0.4s ease; +} + +@keyframes popcorn-shake { + + 0%, + 100% { + transform: rotate(0deg) scale(1); + } + + 20% { + transform: rotate(-15deg) scale(1.05); + } + + 40% { + transform: rotate(10deg) scale(1.05); + } + + 60% { + transform: rotate(-8deg) scale(1.05); + } + + 80% { + transform: rotate(6deg) scale(1.05); + } +} + +/* Форма ввода */ +.watch-chat-form { + display: flex; +} + +.watch-chat-form input { + flex-grow: 1; + border: 1px solid var(--color-border); + background-color: transparent; + color: var(--color-base); + padding-inline: calc(var(--spacing) * 2); + padding-block: calc(var(--spacing) * 3); +} + +.watch-chat-form button { + cursor: pointer; + min-width: 45px; + font-weight: var(--font-weight-bold); + font-size: var(--text-h2); +} \ No newline at end of file diff --git a/src/styles/movie-watch.css b/src/styles/movie-watch.css index b332ab2..d47843a 100644 --- a/src/styles/movie-watch.css +++ b/src/styles/movie-watch.css @@ -9,84 +9,6 @@ height: calc(100vh - var(--main-header-height)); } -/* Плеер */ -#watch-player { - display: flex; - align-items: center; - justify-content: center; - flex-grow: 1; -} - -#watch-player video { - width: 100%; - height: 100%; - object-fit: contain; -} - -/* Чат */ -#watch-chat { - width: 300px; -} - -#watch-chat>div { - display: flex; - flex-direction: column; - overflow: hidden; - height: 100%; -} - -#watch-chat h2 { - position: relative; - box-shadow: var(--box-shadow); - padding-block-end: calc(var(--spacing) * 1); -} - -/* Список сообщений */ -.watch-chat-messages { - flex-grow: 1; - overflow-y: auto; - padding-block-start: calc(var(--spacing) * 2); -} - -.watch-chat-message:first-child { - margin-block-start: 0; -} - -.watch-chat-message { - margin-block: calc(var(--spacing) * 3); - word-wrap: break-word; -} - -.watch-chat-sender { - font-weight: var(--font-weight-bold); - color: var(--color-secondary); -} - -.watch-chat-message-author { - color: var(--color-primary); -} - -/* Форма ввода */ -.watch-chat-form { - display: flex; -} - -.watch-chat-form input { - flex-grow: 1; - border: 1px solid var(--color-border); - background-color: transparent; - color: var(--color-base); - padding-inline: calc(var(--spacing) * 2); - padding-block: calc(var(--spacing) * 3); -} - -.watch-chat-form button { - cursor: pointer; - min-width: 45px; - font-weight: var(--font-weight-bold); - font-size: var(--text-h2); -} - @media (max-width: 1024px) { .watch-container { flex-direction: column; diff --git a/src/styles/player.css b/src/styles/player.css new file mode 100644 index 0000000..fd3772a --- /dev/null +++ b/src/styles/player.css @@ -0,0 +1,12 @@ +/* Плеер */ +#watch-player { + display: flex; + align-items: center; + justify-content: center; + flex-grow: 1; +} + +.player-wrapper { + height: 100%; + flex-grow: 1; +} \ No newline at end of file diff --git a/src/watch.html b/src/watch.html index 8bbe8d2..23541d9 100644 --- a/src/watch.html +++ b/src/watch.html @@ -10,6 +10,8 @@ + + From 50535b37e388e2ee4d7382979ec973a2c9ab814c Mon Sep 17 00:00:00 2001 From: Yevgeny Yakushev Date: Wed, 22 Oct 2025 14:29:06 +0300 Subject: [PATCH 10/10] fixed animation --- src/styles/chat.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/chat.css b/src/styles/chat.css index f347af8..056fda8 100644 --- a/src/styles/chat.css +++ b/src/styles/chat.css @@ -54,7 +54,7 @@ font-size: 4rem; cursor: pointer; user-select: none; - transition: transform 0.1s ease; + transition: all 0.15s ease; background-color: transparent; border: none; }