Сделаем слайд-шоу с 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
, и все продолжит работать.
Читайте также:
- Как хранить и кодировать видео посредством Ruby on Rails, Lambda и S3
- Ruby on Rails меняет всё
- Tailwind CSS: как разработать продвинутую пользовательскую анимацию
Читайте нас в Telegram, VK и Дзен
Перевод статьи Nicolás Galdámez: Syncing a Slideshow across multiple sessions with Ruby on Rails + Hotwire