diff --git a/CHANGELOG.md b/CHANGELOG.md index db23a7c..0d3a08e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## v.3.0.0 + +### Features + +- Redesign the manager, dashboard and waiting screen +- Add new global interaction: realtime transcription with Mistral API +- Add drag-and-drop for interactions and slides + +### Fixes and improvements + +- Improve performance of preview with thumbnails + ## v.2.5.2 ### Fixes and improvements diff --git a/assets/js/app.js b/assets/js/app.js index 9970dc7..1603f94 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -407,6 +407,10 @@ Hooks.Manager = { this.manager.update(); }, }; +const INTERACTION_DRAG_TYPE = "application/x-claper-interaction"; +const isInteractionDrag = (e) => + e.dataTransfer && Array.from(e.dataTransfer.types).includes(INTERACTION_DRAG_TYPE); + Hooks.SlideSortable = { mounted() { this.from = null; @@ -421,7 +425,7 @@ Hooks.SlideSortable = { }); this.el.addEventListener("dragover", (e) => { - if (this.from === null) return; + if (this.from === null && !isInteractionDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; const item = e.target.closest("[data-index]"); @@ -439,6 +443,12 @@ Hooks.SlideSortable = { if (to !== this.from) { this.pushEvent("reorder-slides", { from: this.from, to: to }); } + } else if (item && isInteractionDrag(e)) { + const data = JSON.parse(e.dataTransfer.getData(INTERACTION_DRAG_TYPE)); + const to = parseInt(item.dataset.index); + if (to !== data.position) { + this.pushEvent("move-interaction", { id: data.id, type: data.type, to: to }); + } } this.reset(); }); @@ -458,6 +468,40 @@ Hooks.SlideSortable = { .forEach((n) => n.classList.remove("opacity-40")); }, }; +Hooks.InteractionDrag = { + mounted() { + this.el.addEventListener("dragstart", (e) => { + const item = e.target.closest("[data-interaction-id]"); + if (!item) return; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData( + INTERACTION_DRAG_TYPE, + JSON.stringify({ + id: parseInt(item.dataset.interactionId), + type: item.dataset.interactionType, + position: parseInt(item.dataset.interactionPosition), + }), + ); + const rect = item.getBoundingClientRect(); + const ghost = item.cloneNode(true); + ghost.style.width = `${rect.width}px`; + ghost.style.position = "fixed"; + ghost.style.top = "-1000px"; + ghost.style.left = "-1000px"; + ghost.style.pointerEvents = "none"; + document.body.appendChild(ghost); + e.dataTransfer.setDragImage(ghost, e.clientX - rect.left, e.clientY - rect.top); + setTimeout(() => ghost.remove(), 0); + setTimeout(() => item.classList.add("opacity-40"), 0); + }); + + this.el.addEventListener("dragend", () => { + this.el + .querySelectorAll("[data-interaction-id]") + .forEach((n) => n.classList.remove("opacity-40")); + }); + }, +}; Hooks.OpenPresenter = { open(e) { e.preventDefault(); diff --git a/lib/claper/interactions.ex b/lib/claper/interactions.ex index ce24425..5799208 100644 --- a/lib/claper/interactions.ex +++ b/lib/claper/interactions.ex @@ -73,6 +73,46 @@ defmodule Claper.Interactions do end end + @doc """ + Moves an interaction to another slide position. + + The interaction is disabled when moved so it doesn't stay live on a slide + the audience is not looking at. Returns `{:ok, interaction}` (a no-op when + the position is unchanged) or `{:error, :invalid_position}`. + """ + def move_interaction( + %Events.Event{ + presentation_file: %Presentations.PresentationFile{} = presentation_file + } = event, + interaction, + to + ) + when is_integer(to) do + count = presentation_file.length || 0 + + cond do + to < 0 or to >= count -> {:error, :invalid_position} + to == interaction.position -> {:ok, interaction} + true -> do_move_interaction(event.uuid, interaction, to) + end + end + + defp do_move_interaction(event_uuid, %Polls.Poll{} = poll, to), + do: Polls.update_poll(event_uuid, poll, %{position: to, enabled: false}) + + defp do_move_interaction(event_uuid, %Forms.Form{} = form, to), + do: Forms.update_form(event_uuid, form, %{position: to, enabled: false}) + + defp do_move_interaction(event_uuid, %Embeds.Embed{} = embed, to), + do: Embeds.update_embed(event_uuid, embed, %{position: to, enabled: false}) + + # Quiz.changeset requires quiz_questions via cast_assoc, so they must be + # loaded even though the move doesn't touch them. + defp do_move_interaction(event_uuid, %Quizzes.Quiz{} = quiz, to) do + quiz = Claper.Repo.preload(quiz, quiz_questions: :quiz_question_opts) + Quizzes.update_quiz(event_uuid, quiz, %{position: to, enabled: false}) + end + def enable_interaction(interaction) do Ecto.Multi.new() |> Ecto.Multi.run(:disable_polls, fn _repo, _ -> diff --git a/lib/claper_web/live/event_live/manage.ex b/lib/claper_web/live/event_live/manage.ex index 93d6907..87bf5ba 100644 --- a/lib/claper_web/live/event_live/manage.ex +++ b/lib/claper_web/live/event_live/manage.ex @@ -119,6 +119,18 @@ defmodule ClaperWeb.EventLive.Manage do defp event_id(%{assigns: %{event: event}}), do: event.id + defp get_interaction_for_event("poll", id, socket), + do: Polls.get_poll_for_event(id, event_id(socket)) + + defp get_interaction_for_event("form", id, socket), + do: Forms.get_form_for_event(id, event_id(socket)) + + defp get_interaction_for_event("embed", id, socket), + do: Embeds.get_embed_for_event(id, event_id(socket)) + + defp get_interaction_for_event("quiz", id, socket), + do: Quizzes.get_quiz_for_event(id, event_id(socket)) + @impl true def handle_info(%{event: "presence_diff"}, %{assigns: %{event: event}} = socket) do attendees = Presence.list("event:#{event.uuid}") @@ -257,31 +269,31 @@ defmodule ClaperWeb.EventLive.Manage do end @impl true - def handle_info({:poll_updated, poll}, socket) do + def handle_info({:poll_updated, _poll}, socket) do {:noreply, socket - |> interactions_at_position(poll.position)} + |> interactions_at_position(socket.assigns.state.position)} end @impl true - def handle_info({:embed_updated, embed}, socket) do + def handle_info({:embed_updated, _embed}, socket) do {:noreply, socket - |> interactions_at_position(embed.position)} + |> interactions_at_position(socket.assigns.state.position)} end @impl true - def handle_info({:form_updated, form}, socket) do + def handle_info({:form_updated, _form}, socket) do {:noreply, socket - |> interactions_at_position(form.position)} + |> interactions_at_position(socket.assigns.state.position)} end @impl true - def handle_info({:quiz_updated, quiz}, socket) do + def handle_info({:quiz_updated, _quiz}, socket) do {:noreply, socket - |> interactions_at_position(quiz.position)} + |> interactions_at_position(socket.assigns.state.position)} end @impl true @@ -453,6 +465,33 @@ defmodule ClaperWeb.EventLive.Manage do end end + @impl true + def handle_event( + "move-interaction", + %{"id" => id, "type" => type, "to" => to}, + %{assigns: %{event: event, state: state}} = socket + ) + when is_integer(id) and is_integer(to) and type in ["poll", "form", "embed", "quiz"] do + with interaction when not is_nil(interaction) <- get_interaction_for_event(type, id, socket), + {:ok, _moved} <- Claper.Interactions.move_interaction(event, interaction, to) do + if interaction.enabled do + Phoenix.PubSub.broadcast( + Claper.PubSub, + "event:#{event.uuid}", + {:current_interaction, nil} + ) + end + + {:noreply, socket |> interactions_at_position(state.position)} + else + nil -> + {:noreply, socket} + + {:error, _reason} -> + {:noreply, socket |> put_flash(:error, gettext("Could not move interaction"))} + end + end + @impl true def handle_event( "regenerate-thumbnails", diff --git a/lib/claper_web/live/event_live/manage_interaction_list_component.ex b/lib/claper_web/live/event_live/manage_interaction_list_component.ex index 2df6bd3..fe3562c 100644 --- a/lib/claper_web/live/event_live/manage_interaction_list_component.ex +++ b/lib/claper_web/live/event_live/manage_interaction_list_component.ex @@ -38,7 +38,11 @@ defmodule ClaperWeb.EventLive.ManageInteractionListComponent do ) ~H""" -