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/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/index.html b/src/index.html index d441d1c..dfe1083 100644 --- a/src/index.html +++ b/src/index.html @@ -15,13 +15,7 @@ -
-
-

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

-
-
-
- +
diff --git a/src/scripts/adapters/broadcast-channel-adapter.js b/src/scripts/adapters/broadcast-channel-adapter.js new file mode 100644 index 0000000..4857888 --- /dev/null +++ b/src/scripts/adapters/broadcast-channel-adapter.js @@ -0,0 +1,39 @@ +export default class BroadcastChannelAdapter { + #channel + + constructor(channelName) { + try { + this.#channel = new BroadcastChannel(channelName); + } catch (err) { + console.error("Ошибка создания Broadcast Channel:", err); + } + + } + + /** + * Отправка сообщения + * @param {any} message + */ + send(message) { + try { + this.#channel.postMessage(message); + } catch (err) { + console.error("Ошибка отправки сообщения:", err); + } + } + + /** + * Подписка на новые сообщения + * @param {function(any):void} callback + */ + onMessage(callback) { + try { + this.#channel.onmessage = (event) => callback(event.data); + } catch (err) { + console.error("Ошибка подписки на новые сообщения:", err); + } + } +} + +/** Фабрика для создания канала сообщений */ +export const createMessageChannel = (channelName) => new BroadcastChannelAdapter(channelName); \ No newline at end of file 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 7bc0ed0..6ca0ea4 100644 --- a/src/scripts/index.js +++ b/src/scripts/index.js @@ -1,3 +1,5 @@ +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"; @@ -5,19 +7,41 @@ import MovieModel from "./models/movie-model"; * Класс главной страницы приложения. * Отвечает за инициализацию и рендер списка фильмов. */ -class IndexPage { +class IndexPage extends BaseView { + /** @type {IndexHeaderView} Хэдер */ + indexHeaderView /** @type {MovieListView} Список фильмов */ movieListView - constructor() { + #movies + + constructor(...args) { + super(...args); + // Получаем все фильмы из модели - const movies = MovieModel.getAll(); - // Создаём View для списка фильмов - this.movieListView = new MovieListView("#movie-list-container", movies); + this.#movies = MovieModel.getAll(); + } + + /** Метод для рендера HTML (обязательный для BaseView) */ + _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(); } } @@ -28,6 +52,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/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/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..7691540 --- /dev/null +++ b/src/scripts/views/chat-view.js @@ -0,0 +1,140 @@ +import BaseView from "./base-view.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 + + 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); + }); + + // история сообщений чата привязана к фильму + 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 messageClass = `watch-chat-sender${this.#isMyMessage(sender) ? " watch-chat-message-author" : ""}`; + return ` +
+ ${sender}:  + ${text} +
+ `; + } + + #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); + } + + #addChatMessage(message) { + this.#messages.push(message); + 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.#createList()} +
+ + +
+
+ `; + } + + /** + * Обработчик кликов + * @param {MouseEvent} event + */ + #handleClicks = (event) => { + const sendBtn = event.target.closest(".watch-send-btn"); + if (sendBtn) { + event.preventDefault(); + const input = this._$el.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); + } + + #scrollToBottom() { + const messagesContainer = this._$el.querySelector("#chat-messages"); + if (messagesContainer) { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + } + + #focusOnInput() { + this._$el.querySelector(".chat-input").focus(); + } + + render() { + super.render(); + this.#scrollToBottom(); + this.#focusOnInput(); + } +} diff --git a/src/scripts/views/index-header-view.js b/src/scripts/views/index-header-view.js new file mode 100644 index 0000000..a7e4fd4 --- /dev/null +++ b/src/scripts/views/index-header-view.js @@ -0,0 +1,8 @@ +import BaseView from "./base-view.js"; + +export default class IndexHeaderView extends BaseView { + /** Метод для рендера HTML (обязательный для BaseView) */ + _createInnerHTML() { + return `

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

`; + } +} diff --git a/src/scripts/views/movie-list-view.js b/src/scripts/views/movie-list-view.js index 11601c2..08e5a9e 100644 --- a/src/scripts/views/movie-list-view.js +++ b/src/scripts/views/movie-list-view.js @@ -12,24 +12,15 @@ 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 - Объект фильма * @returns {string} HTML-код карточки */ - #createItem({ id, link, title, subtitle, img, details }) { + #createItem({ id, title, subtitle, img, details }) { return `
- +
${title}
@@ -51,6 +42,7 @@ export default class MovieListView extends BaseView { return `
${this._data.map(this.#createItem.bind(this)).join("")} +
`; } @@ -74,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); } @@ -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 7e12f10..dc81f4e 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 - Объект фильма @@ -68,7 +60,7 @@ export default class MovieModalView extends BaseView {
- Смотреть вместе → + Смотреть вместе → @@ -86,16 +78,13 @@ export default class MovieModalView extends BaseView { alert("Settings are not implemented"); } - /** TODO: Переход на страницу просмотра фильма (заглушка) */ - #navigateToWatchPage(movieId) { - alert("Watch page is not implemented"); - } - /** - * Обработчик кликов по кнопкам модалки + * Обработчик кликов * @param {MouseEvent} event */ - #handleButtons = (event) => { + #handleClicks = (event) => { + event.stopPropagation(); + const closeBtn = event.target.closest("[data-modal-close]"); if (closeBtn) return this.close(); @@ -104,24 +93,18 @@ 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) */ _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 new file mode 100644 index 0000000..86854ec --- /dev/null +++ b/src/scripts/views/movie-watch-view.js @@ -0,0 +1,31 @@ +import BaseView from "./base-view.js"; +import PlayerView from "./player-view.js"; +import ChatView from "./chat-view.js"; + +export default class MovieWatchView extends BaseView { + /** @type {PlayerView} Плеер */ + playerView + /** @type {ChatView} Чат */ + chatView + + _createInnerHTML() { + return ` +
+
+
+
+ `; + } + + render() { + super.render(); + + // Создаём 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..a5606f1 --- /dev/null +++ b/src/scripts/views/player-view.js @@ -0,0 +1,21 @@ +import BaseView from "./base-view.js"; + +export default class PlayerView extends BaseView { + /** Метод для рендера HTML (обязательный для BaseView) */ + _createInnerHTML() { + 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 new file mode 100644 index 0000000..d4b553a --- /dev/null +++ b/src/scripts/views/watch-header-view.js @@ -0,0 +1,8 @@ +import BaseView from "./base-view.js"; + +export default class WatchHeaderView extends BaseView { + /** Метод для рендера HTML (обязательный для BaseView) */ + _createInnerHTML() { + return `

 ${this._data.title}

`; + } +} diff --git a/src/scripts/watch.js b/src/scripts/watch.js new file mode 100644 index 0000000..40caed4 --- /dev/null +++ b/src/scripts/watch.js @@ -0,0 +1,62 @@ +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"; + +/** + * Класс страницы совместного просмотра фильма. + * Отвечает за инициализацию и рендер экрана совместного просмотра. + */ +class WatchPage extends BaseView { + /** @type {WatchHeaderView} Хэдер */ + watchHeaderView + /** @type {MovieWatchView} Экран совместного просмотра */ + movieWatchView + + #movie + + constructor(...args) { + super(...args); + + // Получаем id фильма из ?movie_id + const params = new URLSearchParams(window.location.search); + const movieId = params.get("movie_id"); + const decodedMovieId = decodeURIComponent(movieId); + + // Получаем информацию о фильме из модели + this.#movie = MovieModel.getById(decodedMovieId); + } + + /** Метод для рендера HTML (обязательный для BaseView) */ + _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(); + } +} + +/** + * Инициализация приложения после полной загрузки DOM + * - чтобы все селекторы уже существовали + * - window.watchPage даёт доступ к корневому объекту в консоли браузера (для отладки) + */ +document.addEventListener("DOMContentLoaded", () => { + window.watchPage = new WatchPage("#root"); + window.watchPage.render(); +}); \ No newline at end of file diff --git a/src/styles/chat.css b/src/styles/chat.css new file mode 100644 index 0000000..056fda8 --- /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: all 0.15s 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/common.css b/src/styles/common.css index 17eaea2..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); } *, @@ -64,12 +67,30 @@ h3 { color: var(--color-base2); } -/* header */ +a { + color: var(--color-h1); + text-decoration: none; +} + +.page-wrapper { + display: flex; + flex-direction: column; + width: 100vw; + min-height: 100vh; +} + .main-header { position: sticky; top: calc(var(--spacing) * -3); 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 { + flex-grow: 1; + display: flex; } \ No newline at end of file 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; 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 new file mode 100644 index 0000000..d47843a --- /dev/null +++ b/src/styles/movie-watch.css @@ -0,0 +1,32 @@ +/* Контейнер всего экрана */ +.watch-container { + max-width: 1440px; + display: flex; + gap: calc(var(--spacing) * 4); + padding-block: calc(var(--spacing) * 6); + padding-inline: calc(var(--spacing) * 6); + flex-grow: 1; + height: calc(100vh - var(--main-header-height)); +} + +@media (max-width: 1024px) { + .watch-container { + 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 h2 { + display: none; + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..23541d9 --- /dev/null +++ b/src/watch.html @@ -0,0 +1,23 @@ + + + + + + + Flickmate + + + + + + + + + + + +
+ + + + \ No newline at end of file