Синхронизация слайд-шоу между сеансами на Ruby on Rails и Hotwire

Сделаем слайд-шоу с Ruby on Rails, Hotwire и Tailwind CSS, синхронизируем навигацию между окнами.

Наша цель  —  такой результат:

Конечный результат

Это два окна: слева пользователь авторизован и управляет навигацией по слайд-шоу, справа неавторизован и просматривает то же слайд-шоу с автоматической сменой изображений.

Получим этот результат без кода JavaScript, с Ruby on Rails, Tailwind CSS и Hotwire.

Вот репозиторий GitHub.

Моделирование данных

Для решения понадобится две таблицы: фотографий и пользователей.

Последняя нужна для работы с сеансом пользователя, поскольку управляющий навигацией по фотографиям пользователь авторизуется.

Для названий и URL-адресов фото создадим связанную модель и запустим миграции:

rails generate model Photo title url
rails db:migrate

Для регистрации и авторизации пользователя слайд-шоу нужна библиотека devise, настраиваем ее и создаем таблицу пользователей:

bundle add devise
rails generate devise:install
rails generate devise User

Создание слайд-шоу

Для показа первого фото делаем контроллер и экшен index:

rails generate controller slideshow index

Создастся контроллер с экшеном index, и сгенерируется представление в app/views/slideshow/index.html.erb:

class SlideshowController < ApplicationController
def index
end
end

Чтобы показать слайд-шоу из одного фото с названием, а также элементами управления для перехода к следующему/предыдущему фото, заменим код в app/views/slideshow/index.html.erb на такой:

<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<!-- название фото -->
<p class="my-2 text-center text-xl font-semibold text-gray-800">
EXAMPLE TITLE
</p>

<!-- фото -->
<div class="relative overflow-hidden rounded-lg">
<img src="EXAMPLE_URL" class="w-full" />
</div>

<!-- элементы управления навигацией -->
<button class="absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
</svg>
</span>
</button>

<button class="absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none" />
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
</svg>
</span>
</button>
</div>
</div>

Присваиваем атрибуту src тега <img> допустимый URL-адрес и видим:

Подправляем представление, извлекая код для фото и элементов управления в частичный шаблон.

index.html.erb теперь выглядит так:

<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= render 'photos/photo' %>
</div>
</div>

А это частичный шаблон:

<!-- app/views/photos/_photo.html.erb -->

<!-- название фото -->
<p class="my-2 text-center text-xl font-semibold text-gray-800">
EXAMPLE TITLE
</p>

<!-- фото -->
<div class="relative overflow-hidden rounded-lg">
<img src="EXAMPLE_URL" class="w-full" />
</div>

<!-- элементы управления навигацией -->
<button class="absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
</svg>
</span>
</button>

<button class="absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none" />
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
</svg>
</span>
</button>

Отображение самого фото

В созданном примере информация жестко задана, и данные из базы данных не получаются. Чтобы извлечь фото из БД, внесем изменения в контроллер:

# app/controllers/slideshow_controller.rb
class SlideshowController < ApplicationController
def index
@photo = Photo.first
end
end

Для отображения частичного шаблона app/views/photos/_photo.html.erb используем в индексе переменную @photo:

<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= render @photo %>
</div>
</div>

А для отображения информации, соответствующей фото из базы данных, используем атрибуты title и url:

<!-- app/views/photos/_photo.html.erb -->

<!-- название фото -->
<p class="my-2 text-center text-xl font-semibold text-gray-800">
<%= photo.title %>
</p>

<!-- фото -->
<div class="relative overflow-hidden rounded-lg">
<%= image_tag photo.url, class: 'w-full' %>
</div>

<!-- элементы управления навигацией -->
...

Так, если в БД имеются фото, при доступе к индексу отображается самое первое.

Переход между фотографиями

Теперь самое интересное. Для переключения между фотографиями добавим в контроллер экшены previous (назад) и next (вперед), а для обновления показываемого в данный момент фото используем турбофрейм.

Добавление турбофрейма

Это инструмент для беспроблемного перехода между изображениями без перезагрузки всей страницы.

Вот что написано в официальной документации о турбофреймах:

С турбофреймами предопределенные части страницы обновляются по запросу. Любые ссылки и формы внутри фрейма фиксируются, и после получения ответа содержимое фрейма обновляется автоматически. Не важно, имеется на сервере весь документ или только фрагмент с обновленной версией запрошенного фрейма: для замены имеющегося содержимого из ответа извлекается только этот конкретный фрейм.

Благодаря турбофреймам мы перемещаемся внутри одного фрейма, не перезагружая всей страницы, а пользовательское взаимодействие эффективнее.

Добавим турбофрейм в частичный шаблон:

<!-- app/views/photos/_photo.html.erb -->

<%= turbo_frame_tag :photo do %>
<!-- название фото -->
...

<!-- фото -->
...

<!-- элементы управления навигацией -->
...
<% end %>

Так, когда мы нажимаем на элементы управления навигацией, ответ на запрос отображается во фрейме.

Реализация навигации

Навигация очень простая. В slideshow_controller добавляется два новых маршрута с соответственными им экшенами, последними принимается идентификатор показываемой в данный момент фотографии, исходя из которого отображается следующее или предыдущее фото:

def next
# Если это фото последнее, присваивается первое.
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first

render @photo # Отображается частичный шаблон «_photo»
end

def previous
# Если это фото первое, присваивается последнее
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last

render @photo # Отображается частичный шаблон «_photo»
end

Добавим маршруты:

resources :slideshow, only: %i[index] do
member do
post 'next'
post 'previous'
end
end

Обновим элементы управления для указания ими на новые маршруты:

<%= button_to previous_slideshow_path(photo), class: 'absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>

<%= button_to next_slideshow_path(photo), class: 'absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>

Благодаря этим изменениям мы теперь просматриваем слайд-шоу и легко перемещаемся между фотографиями:

Синхронизация фото в окнах

Немного усложним задачу и синхронизируем окна. Слайд-шоу управляется одним человеком, остальные автоматически видят то же изображение.

В текущем решении, если рядом с используемым окном открыть новое инкогнито, у каждого из них будет своя навигация, смена слайдов в одном окне не сказывается на другом:

Независимая навигация

Скрытие элементов управления навигацией

Поместим элементы управления в новый частичный шаблон  —  так код будет аккуратнее  —  и, чтобы их видел только один, авторизованный пользователь, добавим в представление из библиотеки Devise такое условие:

<!-- app/views/photos/_photo.html.erb -->

<%= turbo_frame_tag :photo do %>
<!-- название фото -->
...

<!-- фото -->
...

<!-- элементы управления навигацией -->
<!-- для проверки, авторизован ли пользователь, в Devise имеется метод «user_signed_in?» -->
<%= render 'photos/controls', photo: photo if user_signed_in? %>
<% end %>
<!-- app/views/photos/_controls.html.erb -->
<%= button_to previous_slideshow_path(photo), class: 'absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>

<%= button_to next_slideshow_path(photo), class: 'absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>

Для отображения авторизованного пользователя добавим в app/views/layouts/application.html.erb следующее:

<%= content_tag :p, class: 'text-sm' do %>
<% if user_signed_in? %>
You are the Presenter
<%= link_to 'Sign out', destroy_user_session_path, data: { turbo_method: :delete }, class: 'underline decoration-sky-500' %>
<% else %>
You are a viewer
<% end %>
<% end %>

Теперь авторизовываемся в левом окне через localhost:3000/users/sign_in и видим элементы управления:

Для неавторизованного пользователя элементы управления навигацией скрыты

Синхронизация навигации

Синхронизируем оба окна турбостримами: когда пользователь слева нажимает кнопку «Вперед» или «Назад», фото справа меняется автоматически.

Вот что написано в официальной документации о турбостримах:

С турбостримами страницы изменяются в виде фрагментов HTML, обернутых в самоисполняемые элементы <turbo-stream>, каждым из которых указывается экшен вместе с целевым идентификатором  —  для объявления, что в нем должно произойти с HTML. Чтобы наполнить приложение обновлениями других пользователей или процессов, эти элементы предоставляются сервером через веб-сокет, SSE или другое средство передачи.

Благодаря веб-сокетам часть страниц обновляется прямо из контроллера, а методами broadcast эти изменения применяются к нескольким сеансам.

Сначала заменим турбофрейм на div и сохраним идентификатор photo  —  для последующей замены содержимого турбостримом:

<!-- app/views/photos/_photo.html.erb -->

<%= content_tag :div, id: :photo do %>
<!-- название фото -->
...

<!-- фото -->
...

<!-- элементы управления навигацией -->
...
<% end %>

В index.html.erb методом turbo_stream_from указываем канал для прослушивания изменений, полученных из контроллера:

<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= turbo_stream_from(:photos) %>

<%= render 'photos/photo' %>
</div>
</div>

То есть применятся все экшены, отправленные через турбострим на канале photos.

Чтобы обновить экшены контроллера и уведомить всех об изменении, элемент с идентификатором photo заменяется на частичный шаблон photos/photos:

def next
# Если это фото последнее, присваивается первое
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first

Turbo::StreamsChannel.broadcast_replace_to(
:photos, # Канал, по которому передается изменение.
target: 'photo', # Идентификатор заменяемого элемента.
partial: 'photos/photo',
locals: { photo: @photo }
)
end

def previous
# Если это фото первое, присваивается последнее
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'photo',
partial: 'photos/photo',
locals: { photo: @photo }
)
end

То же, что и с broadcast_replace_to, делается с broadcast_remove, broadcast_append, broadcast_prepend.

Если нажать в слайд-шоу «Вперед» или «Назад», появится ошибка:

Части, применяемые для отправки через турбостримы, отображаются не в контексте запроса, а с помощью ApplicationRenderer. В этом случае попытки Devise получить доступ к авторизованному пользователю чреваты проблемами.

Чтобы применение метода user_signed_in? происходило не внутри отправки, переместим часть с _controls.html.erb в index.html.erb:

<!-- app/views/photos/_photo.html.erb -->
<%= content_tag :div, id: :photo do %>
<!-- название фото -->
...

<!-- фото -->
...

<!-- 🧹 Мы удалили отсюда вызов частичного шаблона🧹 -->
<% end %>
<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= turbo_stream_from(:photos) %>

<%= render @photo %>

<!-- ✅ Вызов частичного шаблона поместили сюда -->
<%= render 'photos/controls', photo: @photo if user_signed_in? %>
</div>
</div>

Благодаря этому изменению фотографии соответственным образом обновляются. Но кнопки навигации постоянно указывают на одно и то же фото.

Чтобы они обновлялись, как раньше, когда были в одном частичном шаблоне, добавим в контроллер еще поток:

class SlideshowController < ApplicationController
def index
@photo = Photo.first
end

def next
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'photo',
partial: 'photos/photo',
locals: { photo: @photo }
)
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'controls',
partial: 'photos/controls',
locals: { photo: @photo }
)
end

def previous
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'photo',
partial: 'photos/photo',
locals: { photo: @photo }
)
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'controls',
partial: 'photos/controls',
locals: { photo: @photo }
)
end
end

Благодаря этим изменениям окна слайд-шоу синхронизированы:

Окна синхронизированы

Чтобы логику потоковой передачи переместить в собственное представление контроллера, извлечем логику применения broadcast в файл app/views/slideshow/photo.turbo_stream.erb:

# app/views/slideshow/photo.turbo_stream.erb
<%
Turbo::StreamsChannel.broadcast_replace_to(:photos, target: 'photo', partial: 'photos/photo', locals: { photo: @photo })
Turbo::StreamsChannel.broadcast_replace_to(:photos, target: 'controls', partial: 'photos/controls', locals: { photo: @photo })
%>

И внесем в контроллер изменения:

class SlideshowController < ApplicationController
def index
@photo = Photo.first
end

def next
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first
render :photo
end

def previous
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last
render :photo
end
end

Турбостримами ссылка захвачена, представление вызовется в app/views/slideshow/photo.turbo_stream.erb, и все продолжит работать.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Nicolás Galdámez: Syncing a Slideshow across multiple sessions with Ruby on Rails + Hotwire

Предыдущая статьяЛокализация: почему простого перевода пользовательского интерфейса будет недостаточно
Следующая статьяРазработка веб-дэшбордов с использованием React, Material UI, Tailwind CSS и Nivo. Часть 2