From 7a424cbb93d275b500fbb9291a46dd1bf22d06c7 Mon Sep 17 00:00:00 2001 From: Alex Lion Date: Fri, 12 Jun 2026 22:53:24 +0200 Subject: [PATCH] Add slide reordering with drag-and-drop --- assets/js/app.js | 51 +++++++ lib/claper/presentations.ex | 141 ++++++++++++++++-- lib/claper/presentations/presentation_file.ex | 4 +- lib/claper/tasks/converter.ex | 3 +- lib/claper_web/live/event_live/manage.ex | 26 ++++ .../manage_slide_sidebar_component.ex | 7 +- priv/gettext/de/LC_MESSAGES/default.po | 27 ++-- priv/gettext/default.pot | 27 ++-- priv/gettext/en/LC_MESSAGES/default.po | 27 ++-- priv/gettext/es/LC_MESSAGES/default.po | 27 ++-- priv/gettext/fr/LC_MESSAGES/default.po | 27 ++-- priv/gettext/hu/LC_MESSAGES/default.po | 27 ++-- priv/gettext/it/LC_MESSAGES/default.po | 27 ++-- priv/gettext/lv/LC_MESSAGES/default.po | 27 ++-- priv/gettext/nl/LC_MESSAGES/default.po | 27 ++-- priv/gettext/sv/LC_MESSAGES/default.po | 27 ++-- ..._add_slide_order_to_presentation_files.exs | 9 ++ test/claper/presentations_test.exs | 89 +++++++++++ 18 files changed, 473 insertions(+), 127 deletions(-) create mode 100644 priv/repo/migrations/20260612000000_add_slide_order_to_presentation_files.exs diff --git a/assets/js/app.js b/assets/js/app.js index e4f8a9f..9970dc7 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -407,6 +407,57 @@ Hooks.Manager = { this.manager.update(); }, }; +Hooks.SlideSortable = { + mounted() { + this.from = null; + + this.el.addEventListener("dragstart", (e) => { + const item = e.target.closest("[data-index]"); + if (!item) return; + this.from = parseInt(item.dataset.index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", item.dataset.index); + setTimeout(() => item.classList.add("opacity-40"), 0); + }); + + this.el.addEventListener("dragover", (e) => { + if (this.from === null) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + const item = e.target.closest("[data-index]"); + this.clearDropTarget(); + if (item && parseInt(item.dataset.index) !== this.from) { + item.classList.add("ring-2", "ring-primary-500"); + } + }); + + this.el.addEventListener("drop", (e) => { + e.preventDefault(); + const item = e.target.closest("[data-index]"); + if (item && this.from !== null) { + const to = parseInt(item.dataset.index); + if (to !== this.from) { + this.pushEvent("reorder-slides", { from: this.from, to: to }); + } + } + this.reset(); + }); + + this.el.addEventListener("dragend", () => this.reset()); + }, + clearDropTarget() { + this.el + .querySelectorAll("[data-index]") + .forEach((n) => n.classList.remove("ring-2", "ring-primary-500")); + }, + reset() { + this.from = null; + this.clearDropTarget(); + this.el + .querySelectorAll("[data-index]") + .forEach((n) => n.classList.remove("opacity-40")); + }, +}; Hooks.OpenPresenter = { open(e) { e.preventDefault(); diff --git a/lib/claper/presentations.ex b/lib/claper/presentations.ex index 3b1db9d..bbac17c 100644 --- a/lib/claper/presentations.ex +++ b/lib/claper/presentations.ex @@ -57,12 +57,12 @@ defmodule Claper.Presentations do [] presentation -> - get_slide_urls(hash, presentation.length) + get_slide_urls(presentation) end end def get_slide_urls(%PresentationFile{} = presentation) do - get_slide_urls(presentation.hash, presentation.length) + get_slide_urls(presentation.hash, presentation.length, presentation.slide_order) end @doc """ @@ -94,25 +94,25 @@ defmodule Claper.Presentations do @doc """ Returns a list of JPG slide URLs for a given presentation `hash` and - `length`. See also `get_slide_urls/1`. + `length`, optionally reordered by `slide_order`. See also `get_slide_urls/1`. """ - def get_slide_urls(hash, length) + def get_slide_urls(hash, length, slide_order \\ nil) - def get_slide_urls(nil, _), do: [] + def get_slide_urls(nil, _, _), do: [] - def get_slide_urls(hash, length) when is_binary(hash) and is_integer(length) do + def get_slide_urls(hash, length, slide_order) when is_binary(hash) and is_integer(length) do config = Application.get_env(:claper, :presentations) case Keyword.fetch!(config, :storage) do "local" -> - for index <- 1..length do + for index <- slide_indexes(length, slide_order) do "/uploads/#{hash}/#{index}.jpg" end "s3" -> base_url = Keyword.fetch!(config, :s3_public_url) - for index <- 1..length do + for index <- slide_indexes(length, slide_order) do base_url <> "/presentations/#{hash}/#{index}.jpg" end @@ -131,23 +131,24 @@ defmodule Claper.Presentations do def get_slide_thumbnail_urls(%PresentationFile{length: nil}), do: [] def get_slide_thumbnail_urls(%PresentationFile{length: 0}), do: [] - def get_slide_thumbnail_urls(%PresentationFile{hash: hash, length: length}) do - get_slide_thumbnail_urls(hash, length) + def get_slide_thumbnail_urls(%PresentationFile{} = presentation) do + get_slide_thumbnail_urls(presentation.hash, presentation.length, presentation.slide_order) end - def get_slide_thumbnail_urls(hash, length) when is_binary(hash) and is_integer(length) do + def get_slide_thumbnail_urls(hash, length, slide_order \\ nil) + when is_binary(hash) and is_integer(length) do config = Application.get_env(:claper, :presentations) case Keyword.fetch!(config, :storage) do "local" -> - for index <- 1..length do + for index <- slide_indexes(length, slide_order) do "/uploads/#{hash}/thumbs/#{index}.jpg" end "s3" -> base_url = Keyword.fetch!(config, :s3_public_url) - for index <- 1..length do + for index <- slide_indexes(length, slide_order) do base_url <> "/presentations/#{hash}/thumbs/#{index}.jpg" end @@ -156,6 +157,15 @@ defmodule Claper.Presentations do end end + # Display order as a list of 1-based original slide file indexes. A stored + # `slide_order` is only honored when it matches the slide count, so a stale + # order (e.g. from before a re-upload) falls back to the natural order. + defp slide_indexes(count, slide_order) + when is_list(slide_order) and length(slide_order) == count, + do: slide_order + + defp slide_indexes(count, _slide_order), do: Enum.to_list(1..count//1) + @doc """ Returns true when a presentation has slides but no generated thumbnails. """ @@ -248,6 +258,111 @@ defmodule Claper.Presentations do |> broadcast(:state_updated) end + @interaction_schemas [ + Claper.Polls.Poll, + Claper.Forms.Form, + Claper.Embeds.Embed, + Claper.Quizzes.Quiz + ] + + @doc """ + Moves the slide displayed at position `from` to position `to` (both 0-based + display positions). + + Slide files are never renamed (a hash directory can be shared by duplicated + events); instead the per-presentation `slide_order` permutation is updated. + Interactions (polls, forms, embeds, quizzes) and the current presentation + state position are remapped so they stay attached to their slide content. + + Returns `{:ok, presentation_file, presentation_state}` or + `{:error, :invalid_position}`. + """ + def reorder_slides(%PresentationFile{} = presentation_file, from, to) + when is_integer(from) and is_integer(to) do + count = presentation_file.length || 0 + + if from == to or from < 0 or to < 0 or from >= count or to >= count do + {:error, :invalid_position} + else + do_reorder_slides(presentation_file, from, to) + end + end + + defp do_reorder_slides(presentation_file, from, to) do + {moved, rest} = + presentation_file.length + |> slide_indexes(presentation_file.slide_order) + |> List.pop_at(from) + + new_order = List.insert_at(rest, to, moved) + + Repo.transaction(fn -> + presentation_file = + presentation_file + |> PresentationFile.changeset(%{slide_order: new_order}) + |> Repo.update!() + + Enum.each(@interaction_schemas, fn schema -> + remap_interaction_positions(schema, presentation_file.id, from, to) + end) + + state = Repo.get_by(PresentationState, presentation_file_id: presentation_file.id) + new_position = state && remap_position(state.position, from, to) + position_changed = state && new_position != state.position + + state = + if position_changed do + state + |> PresentationState.changeset(%{position: new_position}) + |> Repo.update!() + else + state + end + + {presentation_file, state, position_changed} + end) + |> case do + {:ok, {presentation_file, state, position_changed}} -> + if position_changed, do: broadcast({:ok, state}, :state_updated) + {:ok, presentation_file, state} + + {:error, reason} -> + {:error, reason} + end + end + + # Shifts the `position` column of every interaction so it follows its slide + # content. Interactions on the moved slide are parked at -1 first to avoid + # colliding with the shifted range. + defp remap_interaction_positions(schema, presentation_file_id, from, to) do + base = from(i in schema, where: i.presentation_file_id == ^presentation_file_id) + + base |> where([i], i.position == ^from) |> Repo.update_all(set: [position: -1]) + + if from < to do + base + |> where([i], i.position > ^from and i.position <= ^to) + |> Repo.update_all(inc: [position: -1]) + else + base + |> where([i], i.position >= ^to and i.position < ^from) + |> Repo.update_all(inc: [position: 1]) + end + + base |> where([i], i.position == -1) |> Repo.update_all(set: [position: to]) + end + + defp remap_position(nil, _from, _to), do: nil + defp remap_position(position, from, to) when position == from, do: to + + defp remap_position(position, from, to) when from < to and position > from and position <= to, + do: position - 1 + + defp remap_position(position, from, to) when from > to and position >= to and position < from, + do: position + 1 + + defp remap_position(position, _from, _to), do: position + defp broadcast({:error, _reason} = error, _state), do: error defp broadcast({:ok, state}, event) do diff --git a/lib/claper/presentations/presentation_file.ex b/lib/claper/presentations/presentation_file.ex index 2ea5e5a..cb88aa3 100644 --- a/lib/claper/presentations/presentation_file.ex +++ b/lib/claper/presentations/presentation_file.ex @@ -7,6 +7,7 @@ defmodule Claper.Presentations.PresentationFile do hash: String.t() | nil, length: integer() | nil, status: String.t() | nil, + slide_order: [integer()] | nil, event_id: integer() | nil, polls: [Claper.Polls.Poll.t()] | nil, forms: [Claper.Forms.Form.t()] | nil, @@ -21,6 +22,7 @@ defmodule Claper.Presentations.PresentationFile do field :hash, :string field :length, :integer field :status, :string + field :slide_order, {:array, :integer} belongs_to :event, Claper.Events.Event has_many :polls, Claper.Polls.Poll @@ -37,7 +39,7 @@ defmodule Claper.Presentations.PresentationFile do @doc false def changeset(presentation_file, attrs) do presentation_file - |> cast(attrs, [:length, :status, :hash, :event_id]) + |> cast(attrs, [:length, :status, :hash, :event_id, :slide_order]) |> cast_assoc(:presentation_state) end end diff --git a/lib/claper/tasks/converter.ex b/lib/claper/tasks/converter.ex index 475bed1..36203cb 100644 --- a/lib/claper/tasks/converter.ex +++ b/lib/claper/tasks/converter.ex @@ -256,7 +256,8 @@ defmodule Claper.Tasks.Converter do Claper.Presentations.update_presentation_file(presentation, %{ "hash" => "#{hash}", "length" => length, - "status" => "done" + "status" => "done", + "slide_order" => nil }) do if get_presentation_storage() != "local", do: File.rm_rf!(path) diff --git a/lib/claper_web/live/event_live/manage.ex b/lib/claper_web/live/event_live/manage.ex index eaec397..93d6907 100644 --- a/lib/claper_web/live/event_live/manage.ex +++ b/lib/claper_web/live/event_live/manage.ex @@ -427,6 +427,32 @@ defmodule ClaperWeb.EventLive.Manage do |> interactions_at_position(page)} end + @impl true + def handle_event( + "reorder-slides", + %{"from" => from, "to" => to}, + %{assigns: %{event: event, state: state}} = socket + ) + when is_integer(from) and is_integer(to) and from != to do + case Presentations.reorder_slides(event.presentation_file, from, to) do + {:ok, _presentation_file, new_state} -> + if new_state && new_state.position != state.position do + Phoenix.PubSub.broadcast( + Claper.PubSub, + "event:#{event.uuid}", + {:page_changed, new_state.position} + ) + end + + socket = refresh_event(socket) + + {:noreply, socket |> interactions_at_position(socket.assigns.state.position)} + + {:error, _reason} -> + {:noreply, socket |> put_flash(:error, gettext("Could not reorder slides"))} + end + end + @impl true def handle_event( "regenerate-thumbnails", diff --git a/lib/claper_web/live/event_live/manage_slide_sidebar_component.ex b/lib/claper_web/live/event_live/manage_slide_sidebar_component.ex index 55a2245..0f1b70d 100644 --- a/lib/claper_web/live/event_live/manage_slide_sidebar_component.ex +++ b/lib/claper_web/live/event_live/manage_slide_sidebar_component.ex @@ -40,13 +40,15 @@ defmodule ClaperWeb.EventLive.ManageSlideSidebarComponent do {gettext("Content")} -
+