From 31ed25f1c59c095abeb034a0376cfe569bd0f390 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:21:28 -0400 Subject: [PATCH 01/67] Add presenter notes feature Adds per-slide presenter notes to the event manager page. Notes are automatically extracted from PPTX speaker notes on upload (using SweetXml to parse notesSlide XML), stored in a new presenter_notes table, and displayed in an editable textarea in the manage view that auto-saves on blur. PDF and PPT uploads are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- lib/claper/presentations.ex | 33 ++++++++++ lib/claper/presentations/presenter_note.ex | 20 ++++++ lib/claper/tasks/converter.ex | 63 +++++++++++++++++-- lib/claper_web/live/event_live/manage.ex | 21 ++++++- .../live/event_live/manage.html.heex | 17 +++++ .../20260406000001_create_presenter_notes.exs | 17 +++++ 6 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 lib/claper/presentations/presenter_note.ex create mode 100644 priv/repo/migrations/20260406000001_create_presenter_notes.exs diff --git a/lib/claper/presentations.ex b/lib/claper/presentations.ex index c323ea1d..d6109198 100644 --- a/lib/claper/presentations.ex +++ b/lib/claper/presentations.ex @@ -7,6 +7,7 @@ defmodule Claper.Presentations do alias Claper.Repo alias Claper.Presentations.PresentationFile + alias Claper.Presentations.PresenterNote @doc """ Gets a single presentation_files. @@ -173,6 +174,38 @@ defmodule Claper.Presentations do |> broadcast(:state_updated) end + @doc """ + Returns the content of the presenter note for a given slide position, + or an empty string if no note exists. + """ + def get_note_at_position(presentation_file_id, position) do + case Repo.get_by(PresenterNote, + presentation_file_id: presentation_file_id, + slide_position: position + ) do + nil -> "" + note -> note.content || "" + end + end + + @doc """ + Inserts or updates the presenter note for a given slide position. + """ + def upsert_note(presentation_file_id, position, content) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + %PresenterNote{} + |> PresenterNote.changeset(%{ + presentation_file_id: presentation_file_id, + slide_position: position, + content: content + }) + |> Repo.insert( + on_conflict: [set: [content: content, updated_at: now]], + conflict_target: [:presentation_file_id, :slide_position] + ) + end + defp broadcast({:error, _reason} = error, _state), do: error defp broadcast({:ok, state}, event) do diff --git a/lib/claper/presentations/presenter_note.ex b/lib/claper/presentations/presenter_note.ex new file mode 100644 index 00000000..2ea68c1b --- /dev/null +++ b/lib/claper/presentations/presenter_note.ex @@ -0,0 +1,20 @@ +defmodule Claper.Presentations.PresenterNote do + use Ecto.Schema + import Ecto.Changeset + + schema "presenter_notes" do + field :slide_position, :integer + field :content, :string, default: "" + + belongs_to :presentation_file, Claper.Presentations.PresentationFile + + timestamps() + end + + @doc false + def changeset(note, attrs) do + note + |> cast(attrs, [:slide_position, :content, :presentation_file_id]) + |> validate_required([:slide_position, :presentation_file_id]) + end +end diff --git a/lib/claper/tasks/converter.ex b/lib/claper/tasks/converter.ex index f70bc2c7..026c77d4 100644 --- a/lib/claper/tasks/converter.ex +++ b/lib/claper/tasks/converter.ex @@ -39,7 +39,7 @@ defmodule Claper.Tasks.Converter do file_to_pdf(ext_atom, path, file) |> pdf_to_jpg(path, presentation, user_id) - |> jpg_upload(hash, path, presentation, user_id, is_copy) + |> jpg_upload(hash, path, presentation, user_id, is_copy, ext_atom) end @doc """ @@ -116,7 +116,9 @@ defmodule Claper.Tasks.Converter do failure(presentation, path, user_id) end - defp jpg_upload(%Result{status: 0}, hash, path, presentation, user_id, is_copy) do + defp jpg_upload(result, hash, path, presentation, user_id, is_copy, ext_atom \\ nil) + + defp jpg_upload(%Result{status: 0}, hash, path, presentation, user_id, is_copy, ext_atom) do files = Path.wildcard("#{path}/*.jpg") # assign new hash to avoid cache issues @@ -154,26 +156,77 @@ defmodule Claper.Tasks.Converter do clear(presentation.hash) end - success(presentation, path, new_hash, length(files), user_id) + success(presentation, path, new_hash, length(files), user_id, ext_atom) end - defp jpg_upload(_result, _hash, path, presentation, user_id, _is_copy) do + defp jpg_upload(_result, _hash, path, presentation, user_id, _is_copy, _ext_atom) do failure(presentation, path, user_id) end - defp success(presentation, path, hash, length, user_id) do + defp success(presentation, path, hash, length, user_id, ext_atom \\ nil) do with {:ok, presentation} <- Claper.Presentations.update_presentation_file(presentation, %{ "hash" => "#{hash}", "length" => length, "status" => "done" }) do + # For local storage the directory was already renamed to the new hash, + # so original.pptx lives under the new hash path. + # For S3 storage the original directory (path) is still intact at this point. + pptx_path = + if get_presentation_storage() == "local" do + Path.join([get_presentation_storage_dir(), "uploads", "#{hash}", "original.pptx"]) + else + "#{path}/original.pptx" + end + + extract_and_save_notes(pptx_path, ext_atom, presentation.id, length) + if get_presentation_storage() != "local", do: File.rm_rf!(path) Events.broadcast_user_events(user_id, {:presentation_file_process_done, presentation}) end end + defp extract_and_save_notes(pptx_path, :pptx, presentation_file_id, slide_count) do + case :zip.unzip(String.to_charlist(pptx_path), [:memory]) do + {:ok, files} -> + for i <- 1..slide_count do + filename = String.to_charlist("ppt/notesSlides/notesSlide#{i}.xml") + + case List.keyfind(files, filename, 0) do + {_, content} -> + text = extract_notes_text(content) + + if text != "" do + Claper.Presentations.upsert_note(presentation_file_id, i - 1, text) + end + + nil -> + :ok + end + end + + _ -> + :ok + end + end + + defp extract_and_save_notes(_path, _ext, _presentation_file_id, _slide_count), do: :ok + + defp extract_notes_text(xml_content) do + import SweetXml + + xml_content + |> xpath( + ~x"//*[local-name()='sp'][.//*[local-name()='ph'][@type='body']]//*[local-name()='t']/text()"ls + ) + |> Enum.join(" ") + |> String.trim() + rescue + _ -> "" + end + defp failure(presentation, path, user_id) do with {:ok, presentation} <- Claper.Presentations.update_presentation_file(presentation, %{ diff --git a/lib/claper_web/live/event_live/manage.ex b/lib/claper_web/live/event_live/manage.ex index cd03b67f..3f6b3bcf 100644 --- a/lib/claper_web/live/event_live/manage.ex +++ b/lib/claper_web/live/event_live/manage.ex @@ -65,6 +65,7 @@ defmodule ClaperWeb.EventLive.Manage do timeout: 500 }) |> interactions_at_position(event.presentation_file.presentation_state.position) + |> note_at_position(event.presentation_file.presentation_state.position) {:ok, socket} end @@ -327,7 +328,8 @@ defmodule ClaperWeb.EventLive.Manage do {:noreply, socket |> assign(:state, new_state) - |> interactions_at_position(page)} + |> interactions_at_position(page) + |> note_at_position(page)} end def handle_event("poll-set-active", %{"id" => id}, socket) do @@ -1027,6 +1029,23 @@ defmodule ClaperWeb.EventLive.Manage do {:noreply, socket |> assign(:state, new_state)} end + def handle_event("save-note", %{"content" => content}, socket) do + position = socket.assigns.state.position + + Claper.Presentations.upsert_note( + socket.assigns.event.presentation_file.id, + position, + content + ) + + {:noreply, assign(socket, :current_note, content)} + end + + defp note_at_position(%{assigns: %{event: event}} = socket, position) do + content = Claper.Presentations.get_note_at_position(event.presentation_file.id, position) + assign(socket, :current_note, content) + end + defp interactions_at_position( %{assigns: %{event: event}} = socket, position, diff --git a/lib/claper_web/live/event_live/manage.html.heex b/lib/claper_web/live/event_live/manage.html.heex index 0f1ec081..448539f0 100644 --- a/lib/claper_web/live/event_live/manage.html.heex +++ b/lib/claper_web/live/event_live/manage.html.heex @@ -707,6 +707,23 @@ data-tg-tour={"

#{gettext("This section contains all your interactions.")}

#{gettext("You can add interactions to your presentation slides.")}

"} data-tg-group="manage" > +
0} + class="p-3 border-b border-gray-200 bg-white" + > +

+ {gettext("Presenter notes")} +

+
+ +
+
Date: Mon, 6 Apr 2026 18:57:06 -0400 Subject: [PATCH 02/67] Make notes/interactions panels vertically resizable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fixed-height notes textarea with a draggable Split layout inside the interactions column. Notes start at 2/3 height and interactions at 1/3, with a ••• gutter the presenter can drag to resize freely. Co-Authored-By: Claude Sonnet 4.6 --- lib/claper_web/live/event_live/manage.html.heex | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/claper_web/live/event_live/manage.html.heex b/lib/claper_web/live/event_live/manage.html.heex index 448539f0..957e2b24 100644 --- a/lib/claper_web/live/event_live/manage.html.heex +++ b/lib/claper_web/live/event_live/manage.html.heex @@ -701,7 +701,10 @@ >
0, do: "Split", else: ""}"} + data-type="row" + data-gutter=".gutter-notes" + class={"bg-gray-100 #{if @event.presentation_file.length > 0, do: "md:grid grid-rows-[2fr_10px_1fr] overflow-hidden h-full", else: "overflow-y-auto"}"} data-tg-order="1" data-tg-title={"#{gettext("Your interactions")}"} data-tg-tour={"

#{gettext("This section contains all your interactions.")}

#{gettext("You can add interactions to your presentation slides.")}

"} @@ -709,7 +712,7 @@ >
0} - class="p-3 border-b border-gray-200 bg-white" + class="p-3 bg-white overflow-y-auto" >

{gettext("Presenter notes")} @@ -719,13 +722,19 @@ id="presenter-note-textarea" name="content" phx-debounce="blur" - class="w-full h-24 text-sm border border-gray-300 rounded-md p-2 resize-none focus:outline-none focus:ring-1 focus:ring-primary-400" + class="w-full h-full text-sm border border-gray-300 rounded-md p-2 resize-none focus:outline-none focus:ring-1 focus:ring-primary-400" placeholder={gettext("Add notes for this slide...")} ><%= @current_note %>

+
0} + class="gutter-notes hidden md:block cursor-row-resize col-span-full bg-gray-50 text-center text-gray-300 text-sm leading-3" + > + ••• +
-
+
Date: Mon, 6 Apr 2026 19:33:54 -0400 Subject: [PATCH 03/67] Fix notes textarea not filling its grid pane Switch the notes pane to a flex column layout so the textarea stretches to fill all available height in the Split row pane, rather than staying at a fixed intrinsic height. Co-Authored-By: Claude Sonnet 4.6 --- lib/claper_web/live/event_live/manage.html.heex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/claper_web/live/event_live/manage.html.heex b/lib/claper_web/live/event_live/manage.html.heex index 957e2b24..a695c530 100644 --- a/lib/claper_web/live/event_live/manage.html.heex +++ b/lib/claper_web/live/event_live/manage.html.heex @@ -712,17 +712,17 @@ >
0} - class="p-3 bg-white overflow-y-auto" + class="flex flex-col p-3 bg-white min-h-0" > -

+

{gettext("Presenter notes")}

-
+
From 4f0bd8ba231b18ed7a70cb68d096eefd9d9ac89f Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:46:24 -0400 Subject: [PATCH 04/67] Add rich text editing to presenter notes with Quill.js Replaces the plain textarea with a Quill editor (Snow theme) supporting bold, italic, underline, strikethrough, headings, font color/highlight, ordered/unordered lists, and a clean-format button. Content is stored as HTML. The editor auto-saves 800 ms after the user stops typing and updates instantly when navigating slides via the PresenterNotes LiveView hook. Co-Authored-By: Claude Sonnet 4.6 --- assets/css/app.css | 1 + assets/js/app.js | 3 + assets/js/presenter_notes.js | 70 +++++++++++++++++++ assets/package.json | 1 + .../live/event_live/manage.html.heex | 28 +++++--- 5 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 assets/js/presenter_notes.js diff --git a/assets/css/app.css b/assets/css/app.css index b3d3e4d8..33644dfe 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,5 +1,6 @@ @import 'air-datepicker/air-datepicker.css'; @import 'animate.css/animate.min.css'; +@import 'quill/dist/quill.snow.css'; @import 'tailwindcss'; @import './theme.css' layer(theme); diff --git a/assets/js/app.js b/assets/js/app.js index fdf61dd0..d737080c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,5 +1,6 @@ // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. import "phoenix_html"; +import PresenterNotes from "./presenter_notes"; // Establish Phoenix Socket and LiveView configuration. import { Socket, Presence } from "phoenix"; import { LiveSocket } from "phoenix_live_view"; @@ -682,6 +683,8 @@ Hooks.CSVDownloader = { } }; +Hooks.PresenterNotes = PresenterNotes; + // Merge our custom hooks with the existing hooks Object.assign(Hooks, CustomHooks); diff --git a/assets/js/presenter_notes.js b/assets/js/presenter_notes.js new file mode 100644 index 00000000..5405d39c --- /dev/null +++ b/assets/js/presenter_notes.js @@ -0,0 +1,70 @@ +import Quill from "quill"; + +const TOOLBAR_OPTIONS = [ + [{ header: [1, 2, 3, false] }], + ["bold", "italic", "underline", "strike"], + [{ color: [] }, { background: [] }], + [{ list: "ordered" }, { list: "bullet" }], + ["clean"], +]; + +const EMPTY_HTML = "


"; + +export default { + mounted() { + this._initQuill(); + }, + + updated() { + // LiveView updated the data-content attribute (slide changed). + // Update Quill only if the content actually differs to avoid + // disrupting the cursor while the user is actively typing. + const incoming = this.el.dataset.content || ""; + const current = this._getHtml(); + const normalised = incoming === "" ? EMPTY_HTML : incoming; + + if (current !== normalised) { + this.quill.clipboard.dangerouslyPasteHTML(incoming); + } + }, + + destroyed() { + this.quill = null; + }, + + // ── private ─────────────────────────────────────────────────────────────── + + _initQuill() { + const editorEl = this.el.querySelector("[data-quill-editor]"); + const placeholder = this.el.dataset.placeholder || ""; + const initialContent = this.el.dataset.content || ""; + + this.quill = new Quill(editorEl, { + theme: "snow", + placeholder, + modules: { toolbar: TOOLBAR_OPTIONS }, + }); + + // Load initial slide content + if (initialContent) { + this.quill.clipboard.dangerouslyPasteHTML(initialContent); + } + + // Debounced save: push to LiveView 800 ms after the user stops typing + let debounceTimer; + this.quill.on("text-change", (_delta, _old, source) => { + if (source !== "user") return; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const html = this._getHtml(); + // Treat Quill's empty-document sentinel as an empty string + const content = html === EMPTY_HTML ? "" : html; + this.pushEvent("save-note", { content }); + }, 800); + }); + }, + + _getHtml() { + return this.el.querySelector(".ql-editor")?.innerHTML ?? EMPTY_HTML; + }, +}; diff --git a/assets/package.json b/assets/package.json index 3fe714f6..58343f78 100644 --- a/assets/package.json +++ b/assets/package.json @@ -21,6 +21,7 @@ "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", + "quill": "^2.0.3", "qr-code-styling": "^1.6.0-rc.1", "split-grid": "^1.0.11", "split.js": "^1.6.5", diff --git a/lib/claper_web/live/event_live/manage.html.heex b/lib/claper_web/live/event_live/manage.html.heex index a695c530..1da9af51 100644 --- a/lib/claper_web/live/event_live/manage.html.heex +++ b/lib/claper_web/live/event_live/manage.html.heex @@ -712,20 +712,26 @@ >
0} - class="flex flex-col p-3 bg-white min-h-0" + class="flex flex-col bg-white min-h-0" > -

+

{gettext("Presenter notes")}

-
- -
+
+
+
+
0} From 71c848dd0a79e2775cfc5b6afdfdb348dbbdfcef Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:53:43 -0400 Subject: [PATCH 05/67] Fix Quill toolbar disappearing and add keyboard shortcuts Move all Quill-managed DOM (toolbar + editor) inside a single phx-update="ignore" wrapper so LiveView never patches it away. Use Quill's modules.toolbar to point at the pre-rendered container. Add explicit CMD/Ctrl keyboard bindings for bold, italic, underline, and strikethrough. Add CSS overrides so the editor fills its flex pane. Co-Authored-By: Claude Sonnet 4.6 --- assets/css/app.css | 19 ++++++++++++ assets/js/presenter_notes.js | 30 ++++++++++++++----- .../live/event_live/manage.html.heex | 7 +++-- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 33644dfe..87f568d8 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -2,6 +2,25 @@ @import 'animate.css/animate.min.css'; @import 'quill/dist/quill.snow.css'; +/* Presenter notes – make the Quill container fill its flex/grid pane */ +#quill-root { + display: flex; + flex-direction: column; + min-height: 0; +} +#quill-root .ql-container { + flex: 1; + min-height: 0; + overflow-y: auto; + border-top: none; + font-size: 0.875rem; +} +#quill-root .ql-toolbar { + flex-shrink: 0; + border-left: none; + border-right: none; +} + @import 'tailwindcss'; @import './theme.css' layer(theme); diff --git a/assets/js/presenter_notes.js b/assets/js/presenter_notes.js index 5405d39c..b62a2856 100644 --- a/assets/js/presenter_notes.js +++ b/assets/js/presenter_notes.js @@ -16,9 +16,9 @@ export default { }, updated() { - // LiveView updated the data-content attribute (slide changed). - // Update Quill only if the content actually differs to avoid - // disrupting the cursor while the user is actively typing. + // LiveView updated data-content (slide changed). + // Only update Quill when content actually differs to avoid + // disrupting cursor position while the user is typing. const incoming = this.el.dataset.content || ""; const current = this._getHtml(); const normalised = incoming === "" ? EMPTY_HTML : incoming; @@ -35,6 +35,9 @@ export default { // ── private ─────────────────────────────────────────────────────────────── _initQuill() { + // Both toolbar and editor targets live inside the phx-update="ignore" + // wrapper so LiveView never patches them away. + const toolbarEl = this.el.querySelector("[data-quill-toolbar]"); const editorEl = this.el.querySelector("[data-quill-editor]"); const placeholder = this.el.dataset.placeholder || ""; const initialContent = this.el.dataset.content || ""; @@ -42,22 +45,35 @@ export default { this.quill = new Quill(editorEl, { theme: "snow", placeholder, - modules: { toolbar: TOOLBAR_OPTIONS }, + modules: { + // Point Quill at our pre-rendered toolbar container so it is + // always inside phx-update="ignore" and LiveView never removes it. + toolbar: { container: toolbarEl }, + + // Explicit keyboard shortcuts (CMD/Ctrl + key). + keyboard: { + bindings: { + bold: { key: "B", shortKey: true, handler() { this.quill.format("bold", !this.quill.getFormat().bold); } }, + italic: { key: "I", shortKey: true, handler() { this.quill.format("italic", !this.quill.getFormat().italic); } }, + underline: { key: "U", shortKey: true, handler() { this.quill.format("underline", !this.quill.getFormat().underline); } }, + strike: { key: "D", shortKey: true, handler() { this.quill.format("strike", !this.quill.getFormat().strike); } }, + }, + }, + }, }); - // Load initial slide content + // Load initial slide content. if (initialContent) { this.quill.clipboard.dangerouslyPasteHTML(initialContent); } - // Debounced save: push to LiveView 800 ms after the user stops typing + // Debounced auto-save: push to LiveView 800 ms after the user stops typing. let debounceTimer; this.quill.on("text-change", (_delta, _old, source) => { if (source !== "user") return; clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { const html = this._getHtml(); - // Treat Quill's empty-document sentinel as an empty string const content = html === EMPTY_HTML ? "" : html; this.pushEvent("save-note", { content }); }, 800); diff --git a/lib/claper_web/live/event_live/manage.html.heex b/lib/claper_web/live/event_live/manage.html.heex index 1da9af51..75c52be9 100644 --- a/lib/claper_web/live/event_live/manage.html.heex +++ b/lib/claper_web/live/event_live/manage.html.heex @@ -725,11 +725,12 @@ class="flex-1 flex flex-col min-h-0 overflow-hidden" >
+
+
From 1342fcde40869a763bec0e47f0418509f7271763 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:02:23 -0400 Subject: [PATCH 06/67] Fix Quill toolbar, JS error, and preserve note formatting - Replace data-content attribute with a + <%# Everything Quill touches is inside phx-update="ignore" so LiveView never patches it away %>
Date: Mon, 6 Apr 2026 20:07:16 -0400 Subject: [PATCH 07/67] Fix notes disappearing after typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: save-note updated @current_note, re-rendering the - <%# Everything Quill touches is inside phx-update="ignore" so LiveView never patches it away %> + <%!-- Everything Quill touches is inside phx-update="ignore" so LiveView never patches it away --%>
Date: Mon, 6 Apr 2026 20:15:20 -0400 Subject: [PATCH 09/67] Rewrite Quill integration: fix toolbar and note persistence Root causes: - Toolbar never showed because { container: emptyDiv } tells Quill to USE an existing toolbar, not CREATE one. Pass the options array instead so Quill builds the toolbar itself. - Notes disappeared on slide change because the complex phx-update/script/ updated() approach had race conditions with LiveView DOM patching. New approach (canonical LiveView + third-party editor pattern): - phx-update="ignore" on the entire hook element so LiveView never touches the Quill DOM (toolbar, editor, formatting) after mount - data-initial-content for the first render (read once in mounted()) - push_event("load-note") from the server for slide navigation - pushEvent("save-note") from the client for auto-save - No updated() callback, no - <%!-- Everything Quill touches is inside phx-update="ignore" so LiveView never patches it away --%> -
-
-
-
+
Date: Tue, 7 Apr 2026 10:29:28 -0400 Subject: [PATCH 10/67] Change join screen from full-screen overlay to side-by-side panel The "instructions to join" screen now appears as a left panel alongside the presentation slides instead of covering the entire screen. QR code sizing adapts to the panel width. Co-Authored-By: Claude Opus 4.6 --- assets/js/app.js | 32 ++++--- assets/js/presenter.js | 18 ++-- .../live/event_live/presenter.html.heex | 89 ++++++++++--------- 3 files changed, 70 insertions(+), 69 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index d737080c..49e603a5 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -569,17 +569,25 @@ Hooks.QRCode = { "/e/" + this.el.dataset.code : window.location.href; - this.el.style.width = document.documentElement.clientWidth * 0.27 + "px"; - this.el.style.height = document.documentElement.clientWidth * 0.27 + "px"; + + var qrSize; + if (this.el.dataset.panel) { + // Side panel mode: size relative to panel width + var panelWidth = this.el.closest("#joinScreen")?.clientWidth || 300; + qrSize = Math.min(panelWidth * 0.6, document.documentElement.clientHeight * 0.3); + } else if (this.el.dataset.dynamic) { + qrSize = document.documentElement.clientWidth * 0.25; + } else { + qrSize = 240; + } + + this.el.style.width = (qrSize * 1.08) + "px"; + this.el.style.height = (qrSize * 1.08) + "px"; if (this.qrCode == null) { this.qrCode = new QRCodeStyling({ - width: this.el.dataset.dynamic - ? document.documentElement.clientWidth * 0.25 - : 240, - height: this.el.dataset.dynamic - ? document.documentElement.clientWidth * 0.25 - : 240, + width: qrSize, + height: qrSize, margin: 0, data: url, cornersSquareOptions: { @@ -601,12 +609,8 @@ Hooks.QRCode = { this.qrCode.append(this.el); } else { this.qrCode.update({ - width: this.el.dataset.dynamic - ? document.documentElement.clientWidth * 0.25 - : 240, - height: this.el.dataset.dynamic - ? document.documentElement.clientWidth * 0.25 - : 240, + width: qrSize, + height: qrSize, }); } }, diff --git a/assets/js/presenter.js b/assets/js/presenter.js index 53b77046..55310a10 100644 --- a/assets/js/presenter.js +++ b/assets/js/presenter.js @@ -90,20 +90,14 @@ export class Presenter { }); this.context.handleEvent("join-screen-visible", (data) => { + const joinScreen = document.getElementById("joinScreen"); + if (!joinScreen) return; if (data.value) { - document - .getElementById("joinScreen") - .classList.remove("animate__animated", "animate__fadeOut"); - document - .getElementById("joinScreen") - .classList.add("animate__animated", "animate__fadeIn"); + joinScreen.classList.remove("hidden"); + joinScreen.classList.add("flex"); } else { - document - .getElementById("joinScreen") - .classList.remove("animate__animated", "animate__fadeIn"); - document - .getElementById("joinScreen") - .classList.add("animate__animated", "animate__fadeOut"); + joinScreen.classList.remove("flex"); + joinScreen.classList.add("hidden"); } }); diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 7fb12c82..311a774f 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -16,32 +16,7 @@ data-hash={@event.presentation_file.hash} data-current-page={@state.position} > - -
-
- - {gettext("Scan to interact in real-time")} - -
-
- - {gettext("Or go to %{url} and use the code:", url: @host)} - - - #{String.upcase(@event.code)} - -
-
+ <%!-- JOIN SCREEN is rendered inside the slides area as a side panel --%> <%= if @current_poll do %>
•••
- -
- <%= if @current_embed do %> - -
- <.live_component - id="embed-component" - module={ClaperWeb.EventLive.EmbedIframeComponent} - provider={@current_embed.provider} - content={@current_embed.content} + +
+ +
+ + {gettext("Scan to interact in real-time")} + +
+
+ + {gettext("Or go to %{url} and use the code:", url: @host)} + + + #{String.upcase(@event.code)} + +
+ +
+ <%= if @current_embed do %> + +
+ <.live_component + id="embed-component" + module={ClaperWeb.EventLive.EmbedIframeComponent} + provider={@current_embed.provider} + content={@current_embed.content} + /> +
+ <% end %> +
+
- <% end %> -
-
From 27f9b1bcfe0704d1e1af934da53a93bbe8194e5f Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:35:59 -0400 Subject: [PATCH 11/67] Shrink join screen panel to 20% width, slides fill full height Reduced panel from w-2/5 to w-1/5, scaled down text sizes, and made slides fill the full viewport height with object-contain scaling. Co-Authored-By: Claude Opus 4.6 --- assets/js/app.js | 4 ++-- .../live/event_live/presenter.html.heex | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 49e603a5..fd6885b8 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -573,8 +573,8 @@ Hooks.QRCode = { var qrSize; if (this.el.dataset.panel) { // Side panel mode: size relative to panel width - var panelWidth = this.el.closest("#joinScreen")?.clientWidth || 300; - qrSize = Math.min(panelWidth * 0.6, document.documentElement.clientHeight * 0.3); + var panelWidth = this.el.closest("#joinScreen")?.clientWidth || 200; + qrSize = Math.min(panelWidth * 0.75, document.documentElement.clientHeight * 0.25); } else if (this.el.dataset.dynamic) { qrSize = document.documentElement.clientWidth * 0.25; } else { diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 311a774f..5cf2b144 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -182,9 +182,9 @@
- + {gettext("Scan to interact in real-time")}
- + {gettext("Or go to %{url} and use the code:", url: @host)} - + #{String.upcase(@event.code)}
-
+
<%= if @current_embed do %> -
+
<.live_component id="embed-component" module={ClaperWeb.EventLive.EmbedIframeComponent} @@ -217,11 +217,11 @@ />
<% end %> -
+
From 1e320f4bc3e17f099835c4f0796cc1e13abd0c58 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:45:17 -0400 Subject: [PATCH 12/67] Fix slide scaling: fill full viewport height with object-fit contain Add CSS rules targeting tiny-slider wrapper elements to ensure they all fill 100vh. Slide images now use width/height 100% with object-fit: contain so they scale proportionally with viewport size. Co-Authored-By: Claude Opus 4.6 --- .../live/event_live/presenter.html.heex | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 5cf2b144..9ae7359a 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -7,6 +7,28 @@ width: 100%; height: 100%; } + + /* Ensure tiny-slider wrappers fill the full height */ + #slides, #slider, #slider > .tns-outer, + #slider > .tns-outer > .tns-ovh, + #slider > .tns-outer > .tns-ovh > .tns-inner, + #slider .tns-slider, + #slider .tns-item { + height: 100vh; + } + + #slider .tns-item { + display: flex; + align-items: center; + justify-content: center; + background: black; + } + + #slider .tns-item img { + width: 100%; + height: 100%; + object-fit: contain; + }
{gettext("Scan to interact in real-time")} @@ -205,7 +227,7 @@
-
+
<%= if @current_embed do %>
@@ -217,11 +239,10 @@ />
<% end %> -
+
From 83ca4c439a7f4bf0a03bcceb10469e4dd9d2d289 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:51:42 -0400 Subject: [PATCH 13/67] Respect aspect ratio for slide and join panel sizing Use JS to calculate the correct wrapper height from the slide image's natural aspect ratio and available width, capped at viewport height. Both the join panel and slide inherit this height so they stay proportional. Recalculates on resize, join-screen toggle, and chat-visible toggle. Co-Authored-By: Claude Opus 4.6 --- assets/js/presenter.js | 41 +++++++++++++++++++ .../live/event_live/presenter.html.heex | 17 ++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/assets/js/presenter.js b/assets/js/presenter.js index 55310a10..09c4f0ef 100644 --- a/assets/js/presenter.js +++ b/assets/js/presenter.js @@ -8,6 +8,32 @@ export class Presenter { this.hash = context.el.dataset.hash; } + fitSlideArea() { + const wrapper = document.getElementById("slides-join-wrapper"); + if (!wrapper) return; + + const img = document.querySelector("#slider .tns-item img, #slider img"); + if (!img || !img.naturalWidth || !img.naturalHeight) return; + + const ratio = img.naturalWidth / img.naturalHeight; + const vh = window.innerHeight; + + // Available width for the slide image (exclude join panel if visible) + const joinScreen = document.getElementById("joinScreen"); + const joinWidth = + joinScreen && !joinScreen.classList.contains("hidden") + ? joinScreen.offsetWidth + : 0; + const slideWidth = wrapper.parentElement.clientWidth - joinWidth; + + // Height that preserves the slide's aspect ratio at the available width + let height = slideWidth / ratio; + // Never exceed viewport height + height = Math.min(height, vh); + + wrapper.style.height = height + "px"; + } + init(refresh = false) { this.slider = tns({ container: "#slider", @@ -24,6 +50,18 @@ export class Presenter { nav: false, }); + // Fit slide area once first image is loaded, then on every resize + const firstImg = document.querySelector("#slider img"); + if (firstImg) { + const doFit = () => this.fitSlideArea(); + if (firstImg.complete) { + doFit(); + } else { + firstImg.addEventListener("load", doFit, { once: true }); + } + window.addEventListener("resize", doFit); + } + if (refresh) { return; } @@ -69,6 +107,7 @@ export class Presenter { .getElementById("pinned-post-list") .classList.add("animate__animated", "animate__fadeOutLeft"); } + requestAnimationFrame(() => this.fitSlideArea()); }); this.context.handleEvent("poll-visible", (data) => { @@ -99,6 +138,8 @@ export class Presenter { joinScreen.classList.remove("flex"); joinScreen.classList.add("hidden"); } + // Recalculate after the layout shift + requestAnimationFrame(() => this.fitSlideArea()); }); window.addEventListener("keyup", (e) => { diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 9ae7359a..5a5fe46a 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -8,25 +8,24 @@ height: 100%; } - /* Ensure tiny-slider wrappers fill the full height */ + /* tiny-slider wrappers: fill parent height (set by JS) */ #slides, #slider, #slider > .tns-outer, #slider > .tns-outer > .tns-ovh, #slider > .tns-outer > .tns-ovh > .tns-inner, #slider .tns-slider, #slider .tns-item { - height: 100vh; + height: 100% !important; } #slider .tns-item { - display: flex; + display: flex !important; align-items: center; justify-content: center; - background: black; } #slider .tns-item img { - width: 100%; - height: 100%; + max-width: 100%; + max-height: 100%; object-fit: contain; } @@ -200,11 +199,11 @@ •••
-
+
{gettext("Scan to interact in real-time")} @@ -227,7 +226,7 @@
-
+
<%= if @current_embed do %>
From 9203fdfb31c954e7d31f969c464f774b68eabc96 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:54:32 -0400 Subject: [PATCH 14/67] Fix black bars: size wrapper from full-width slide ratio, shrink QR Calculate wrapper height from the slide's aspect ratio at full available width, so the join panel doesn't reduce slide height. QR code now caps at 40% of panel height to avoid overflow. Co-Authored-By: Claude Opus 4.6 --- assets/js/app.js | 9 ++++++--- assets/js/presenter.js | 25 +++++++++++-------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index fd6885b8..40986559 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -572,9 +572,12 @@ Hooks.QRCode = { var qrSize; if (this.el.dataset.panel) { - // Side panel mode: size relative to panel width - var panelWidth = this.el.closest("#joinScreen")?.clientWidth || 200; - qrSize = Math.min(panelWidth * 0.75, document.documentElement.clientHeight * 0.25); + // Side panel mode: size relative to panel dimensions + var panel = this.el.closest("#joinScreen"); + var panelWidth = panel?.clientWidth || 200; + var panelHeight = panel?.clientHeight || 400; + // QR should leave room for text above and below it + qrSize = Math.min(panelWidth * 0.7, panelHeight * 0.4); } else if (this.el.dataset.dynamic) { qrSize = document.documentElement.clientWidth * 0.25; } else { diff --git a/assets/js/presenter.js b/assets/js/presenter.js index 09c4f0ef..a6d6e1da 100644 --- a/assets/js/presenter.js +++ b/assets/js/presenter.js @@ -18,20 +18,17 @@ export class Presenter { const ratio = img.naturalWidth / img.naturalHeight; const vh = window.innerHeight; - // Available width for the slide image (exclude join panel if visible) - const joinScreen = document.getElementById("joinScreen"); - const joinWidth = - joinScreen && !joinScreen.classList.contains("hidden") - ? joinScreen.offsetWidth - : 0; - const slideWidth = wrapper.parentElement.clientWidth - joinWidth; - - // Height that preserves the slide's aspect ratio at the available width - let height = slideWidth / ratio; - // Never exceed viewport height - height = Math.min(height, vh); - - wrapper.style.height = height + "px"; + // Total width available to the wrapper (the grid cell) + const totalWidth = wrapper.parentElement.clientWidth; + + // Height if the slide used the FULL width (no join panel) + // This is the max height the slide would naturally be + const fullHeight = Math.min(totalWidth / ratio, vh); + + // Always use the full-width-derived height so the slide fills the + // viewport the same way regardless of whether the join panel is open. + // The join panel simply sits beside the slide within this height. + wrapper.style.height = fullHeight + "px"; } init(refresh = false) { From 6e140ef2f299f51e0d1713d0cac9e571a26d0e48 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:55:34 -0400 Subject: [PATCH 15/67] Fix wrapper height: calculate from actual slide width, not full width The wrapper height now accounts for the join panel's 20% width so the slide and panel heights match exactly. Chat toggle recalculates with a delay to let the grid animation settle. Co-Authored-By: Claude Opus 4.6 --- assets/js/presenter.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/assets/js/presenter.js b/assets/js/presenter.js index a6d6e1da..ac919d87 100644 --- a/assets/js/presenter.js +++ b/assets/js/presenter.js @@ -18,17 +18,22 @@ export class Presenter { const ratio = img.naturalWidth / img.naturalHeight; const vh = window.innerHeight; + // Determine how much width the join panel consumes + const joinScreen = document.getElementById("joinScreen"); + const joinVisible = joinScreen && !joinScreen.classList.contains("hidden"); + // Total width available to the wrapper (the grid cell) const totalWidth = wrapper.parentElement.clientWidth; - // Height if the slide used the FULL width (no join panel) - // This is the max height the slide would naturally be - const fullHeight = Math.min(totalWidth / ratio, vh); + // Width the slide actually gets (exclude join panel) + const slideWidth = joinVisible ? totalWidth * 0.8 : totalWidth; + + // Height that matches the slide's aspect ratio at its actual width + let height = slideWidth / ratio; + // Cap at viewport height + height = Math.min(height, vh); - // Always use the full-width-derived height so the slide fills the - // viewport the same way regardless of whether the join panel is open. - // The join panel simply sits beside the slide within this height. - wrapper.style.height = fullHeight + "px"; + wrapper.style.height = height + "px"; } init(refresh = false) { @@ -104,7 +109,8 @@ export class Presenter { .getElementById("pinned-post-list") .classList.add("animate__animated", "animate__fadeOutLeft"); } - requestAnimationFrame(() => this.fitSlideArea()); + // Delay to let grid layout settle after chat panel animation + setTimeout(() => this.fitSlideArea(), 350); }); this.context.handleEvent("poll-visible", (data) => { From c241a912224f2bf8114d9084ed5524dd52a50fb8 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:59:21 -0400 Subject: [PATCH 16/67] Measure actual slides div width instead of guessing percentages fitSlideArea() now reads slidesDiv.clientWidth directly, which is correct regardless of chat/join panel combination. All callers use requestAnimationFrame or setTimeout to ensure measurements happen after layout settles. Co-Authored-By: Claude Opus 4.6 --- assets/js/presenter.js | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/assets/js/presenter.js b/assets/js/presenter.js index ac919d87..fd6efd21 100644 --- a/assets/js/presenter.js +++ b/assets/js/presenter.js @@ -10,7 +10,8 @@ export class Presenter { fitSlideArea() { const wrapper = document.getElementById("slides-join-wrapper"); - if (!wrapper) return; + const slidesDiv = document.getElementById("slides"); + if (!wrapper || !slidesDiv) return; const img = document.querySelector("#slider .tns-item img, #slider img"); if (!img || !img.naturalWidth || !img.naturalHeight) return; @@ -18,15 +19,9 @@ export class Presenter { const ratio = img.naturalWidth / img.naturalHeight; const vh = window.innerHeight; - // Determine how much width the join panel consumes - const joinScreen = document.getElementById("joinScreen"); - const joinVisible = joinScreen && !joinScreen.classList.contains("hidden"); - - // Total width available to the wrapper (the grid cell) - const totalWidth = wrapper.parentElement.clientWidth; - - // Width the slide actually gets (exclude join panel) - const slideWidth = joinVisible ? totalWidth * 0.8 : totalWidth; + // Measure the actual rendered width of the slides div (flex-1 handles + // all combinations of chat / join panel visibility automatically) + const slideWidth = slidesDiv.clientWidth; // Height that matches the slide's aspect ratio at its actual width let height = slideWidth / ratio; @@ -55,7 +50,7 @@ export class Presenter { // Fit slide area once first image is loaded, then on every resize const firstImg = document.querySelector("#slider img"); if (firstImg) { - const doFit = () => this.fitSlideArea(); + const doFit = () => requestAnimationFrame(() => this.fitSlideArea()); if (firstImg.complete) { doFit(); } else { @@ -141,8 +136,8 @@ export class Presenter { joinScreen.classList.remove("flex"); joinScreen.classList.add("hidden"); } - // Recalculate after the layout shift - requestAnimationFrame(() => this.fitSlideArea()); + // Recalculate after the layout shift settles + setTimeout(() => this.fitSlideArea(), 50); }); window.addEventListener("keyup", (e) => { From 490d28cc8c330393b615215a76786432f0682257 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:03:00 -0400 Subject: [PATCH 17/67] Fill full viewport: all panels use 100vh, remove JS height calc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All panels (join, slides, chat) now fill the full viewport height. Slide images use object-fit:contain within 100vh to preserve aspect ratio. Removed fitSlideArea() JS — pure CSS handles everything. Co-Authored-By: Claude Opus 4.6 --- assets/js/presenter.js | 39 ------------------- .../live/event_live/presenter.html.heex | 11 +++--- 2 files changed, 6 insertions(+), 44 deletions(-) diff --git a/assets/js/presenter.js b/assets/js/presenter.js index fd6efd21..55310a10 100644 --- a/assets/js/presenter.js +++ b/assets/js/presenter.js @@ -8,29 +8,6 @@ export class Presenter { this.hash = context.el.dataset.hash; } - fitSlideArea() { - const wrapper = document.getElementById("slides-join-wrapper"); - const slidesDiv = document.getElementById("slides"); - if (!wrapper || !slidesDiv) return; - - const img = document.querySelector("#slider .tns-item img, #slider img"); - if (!img || !img.naturalWidth || !img.naturalHeight) return; - - const ratio = img.naturalWidth / img.naturalHeight; - const vh = window.innerHeight; - - // Measure the actual rendered width of the slides div (flex-1 handles - // all combinations of chat / join panel visibility automatically) - const slideWidth = slidesDiv.clientWidth; - - // Height that matches the slide's aspect ratio at its actual width - let height = slideWidth / ratio; - // Cap at viewport height - height = Math.min(height, vh); - - wrapper.style.height = height + "px"; - } - init(refresh = false) { this.slider = tns({ container: "#slider", @@ -47,18 +24,6 @@ export class Presenter { nav: false, }); - // Fit slide area once first image is loaded, then on every resize - const firstImg = document.querySelector("#slider img"); - if (firstImg) { - const doFit = () => requestAnimationFrame(() => this.fitSlideArea()); - if (firstImg.complete) { - doFit(); - } else { - firstImg.addEventListener("load", doFit, { once: true }); - } - window.addEventListener("resize", doFit); - } - if (refresh) { return; } @@ -104,8 +69,6 @@ export class Presenter { .getElementById("pinned-post-list") .classList.add("animate__animated", "animate__fadeOutLeft"); } - // Delay to let grid layout settle after chat panel animation - setTimeout(() => this.fitSlideArea(), 350); }); this.context.handleEvent("poll-visible", (data) => { @@ -136,8 +99,6 @@ export class Presenter { joinScreen.classList.remove("flex"); joinScreen.classList.add("hidden"); } - // Recalculate after the layout shift settles - setTimeout(() => this.fitSlideArea(), 50); }); window.addEventListener("keyup", (e) => { diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 5a5fe46a..65bb1e89 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -8,13 +8,14 @@ height: 100%; } - /* tiny-slider wrappers: fill parent height (set by JS) */ + /* All elements from wrapper down to slide image fill 100vh */ + #slides-join-wrapper, #slides, #slider, #slider > .tns-outer, #slider > .tns-outer > .tns-ovh, #slider > .tns-outer > .tns-ovh > .tns-inner, #slider .tns-slider, #slider .tns-item { - height: 100% !important; + height: 100vh !important; } #slider .tns-item { @@ -25,7 +26,7 @@ #slider .tns-item img { max-width: 100%; - max-height: 100%; + max-height: 100vh; object-fit: contain; } @@ -199,11 +200,11 @@ •••
-
+
{gettext("Scan to interact in real-time")} From 19a3e469305a1adf9ff9e2a89d61738241c794f5 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:10:37 -0400 Subject: [PATCH 18/67] Proportional scaling: calculate wrapper to exactly fit slide aspect ratio JS now computes both width and height of the wrapper so the slide fills it with zero wasted space. The join panel width is set proportionally (20% of wrapper). The wrapper is centered with margin:auto. Everything scales proportionally when the window resizes. Co-Authored-By: Claude Opus 4.6 --- assets/js/presenter.js | 50 +++++++++++++++++++ .../live/event_live/presenter.html.heex | 14 +++--- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/assets/js/presenter.js b/assets/js/presenter.js index 55310a10..76f200f4 100644 --- a/assets/js/presenter.js +++ b/assets/js/presenter.js @@ -8,6 +8,39 @@ export class Presenter { this.hash = context.el.dataset.hash; } + fitSlideArea() { + const wrapper = document.getElementById("slides-join-wrapper"); + if (!wrapper) return; + + const img = document.querySelector("#slider .tns-item img, #slider img"); + if (!img || !img.naturalWidth || !img.naturalHeight) return; + + const R = img.naturalWidth / img.naturalHeight; // slide aspect ratio + const vh = window.innerHeight; + const availW = wrapper.parentElement.clientWidth; // grid cell width + + const joinScreen = document.getElementById("joinScreen"); + const joinVisible = joinScreen && !joinScreen.classList.contains("hidden"); + // Slide gets this fraction of the wrapper width + const slideFrac = joinVisible ? 0.8 : 1.0; + + // Maximize wrapper so the slide fills either the full available width + // or the full viewport height — whichever is the binding constraint. + // Constraint 1: wrapperW <= availW + // Constraint 2: slideH = slideFrac * wrapperW / R <= vh + // → wrapperW <= vh * R / slideFrac + const wrapperW = Math.min(availW, vh * R / slideFrac); + const wrapperH = slideFrac * wrapperW / R; + + wrapper.style.width = wrapperW + "px"; + wrapper.style.height = wrapperH + "px"; + + // Set join panel width explicitly (proportional to wrapper) + if (joinScreen && joinVisible) { + joinScreen.style.width = (wrapperW - wrapperW * slideFrac) + "px"; + } + } + init(refresh = false) { this.slider = tns({ container: "#slider", @@ -24,7 +57,20 @@ export class Presenter { nav: false, }); + // Fit slide area once first image is loaded, then on every resize + const firstImg = document.querySelector("#slider img"); + if (firstImg) { + const doFit = () => this.fitSlideArea(); + if (firstImg.complete) { + doFit(); + } else { + firstImg.addEventListener("load", doFit, { once: true }); + } + window.addEventListener("resize", doFit); + } + if (refresh) { + this.fitSlideArea(); return; } @@ -69,6 +115,8 @@ export class Presenter { .getElementById("pinned-post-list") .classList.add("animate__animated", "animate__fadeOutLeft"); } + // Grid columns change — recalculate after layout settles + setTimeout(() => this.fitSlideArea(), 350); }); this.context.handleEvent("poll-visible", (data) => { @@ -99,6 +147,8 @@ export class Presenter { joinScreen.classList.remove("flex"); joinScreen.classList.add("hidden"); } + // Recalculate — slide area width changes when join panel toggles + setTimeout(() => this.fitSlideArea(), 50); }); window.addEventListener("keyup", (e) => { diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 65bb1e89..6334121e 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -8,14 +8,13 @@ height: 100%; } - /* All elements from wrapper down to slide image fill 100vh */ - #slides-join-wrapper, + /* All elements inherit height from wrapper (sized by JS) */ #slides, #slider, #slider > .tns-outer, #slider > .tns-outer > .tns-ovh, #slider > .tns-outer > .tns-ovh > .tns-inner, #slider .tns-slider, #slider .tns-item { - height: 100vh !important; + height: 100% !important; } #slider .tns-item { @@ -24,9 +23,10 @@ justify-content: center; } + /* Slide image fills its container exactly (container is pre-calculated to match aspect ratio) */ #slider .tns-item img { - max-width: 100%; - max-height: 100vh; + width: 100%; + height: 100%; object-fit: contain; } @@ -200,11 +200,11 @@ •••
-
+
{gettext("Scan to interact in real-time")} From 80c510d0accd43044673853395adfc29aa57d2c7 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:14:21 -0400 Subject: [PATCH 19/67] Simplify to pure CSS 100vh layout, fix grid stretch Remove all JS fitSlideArea() sizing. Use CSS-only approach: grid uses items-stretch + h-screen so all children fill viewport height. Slide image uses max-width/max-height 100% with object-fit:contain. Chat, join panel, and slides all fill the full viewport height naturally. Co-Authored-By: Claude Opus 4.6 --- assets/js/presenter.js | 50 ------------------- .../live/event_live/presenter.html.heex | 25 +++++++--- 2 files changed, 17 insertions(+), 58 deletions(-) diff --git a/assets/js/presenter.js b/assets/js/presenter.js index 76f200f4..55310a10 100644 --- a/assets/js/presenter.js +++ b/assets/js/presenter.js @@ -8,39 +8,6 @@ export class Presenter { this.hash = context.el.dataset.hash; } - fitSlideArea() { - const wrapper = document.getElementById("slides-join-wrapper"); - if (!wrapper) return; - - const img = document.querySelector("#slider .tns-item img, #slider img"); - if (!img || !img.naturalWidth || !img.naturalHeight) return; - - const R = img.naturalWidth / img.naturalHeight; // slide aspect ratio - const vh = window.innerHeight; - const availW = wrapper.parentElement.clientWidth; // grid cell width - - const joinScreen = document.getElementById("joinScreen"); - const joinVisible = joinScreen && !joinScreen.classList.contains("hidden"); - // Slide gets this fraction of the wrapper width - const slideFrac = joinVisible ? 0.8 : 1.0; - - // Maximize wrapper so the slide fills either the full available width - // or the full viewport height — whichever is the binding constraint. - // Constraint 1: wrapperW <= availW - // Constraint 2: slideH = slideFrac * wrapperW / R <= vh - // → wrapperW <= vh * R / slideFrac - const wrapperW = Math.min(availW, vh * R / slideFrac); - const wrapperH = slideFrac * wrapperW / R; - - wrapper.style.width = wrapperW + "px"; - wrapper.style.height = wrapperH + "px"; - - // Set join panel width explicitly (proportional to wrapper) - if (joinScreen && joinVisible) { - joinScreen.style.width = (wrapperW - wrapperW * slideFrac) + "px"; - } - } - init(refresh = false) { this.slider = tns({ container: "#slider", @@ -57,20 +24,7 @@ export class Presenter { nav: false, }); - // Fit slide area once first image is loaded, then on every resize - const firstImg = document.querySelector("#slider img"); - if (firstImg) { - const doFit = () => this.fitSlideArea(); - if (firstImg.complete) { - doFit(); - } else { - firstImg.addEventListener("load", doFit, { once: true }); - } - window.addEventListener("resize", doFit); - } - if (refresh) { - this.fitSlideArea(); return; } @@ -115,8 +69,6 @@ export class Presenter { .getElementById("pinned-post-list") .classList.add("animate__animated", "animate__fadeOutLeft"); } - // Grid columns change — recalculate after layout settles - setTimeout(() => this.fitSlideArea(), 350); }); this.context.handleEvent("poll-visible", (data) => { @@ -147,8 +99,6 @@ export class Presenter { joinScreen.classList.remove("flex"); joinScreen.classList.add("hidden"); } - // Recalculate — slide area width changes when join panel toggles - setTimeout(() => this.fitSlideArea(), 50); }); window.addEventListener("keyup", (e) => { diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 6334121e..646c5f74 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -8,8 +8,18 @@ height: 100%; } - /* All elements inherit height from wrapper (sized by JS) */ - #slides, #slider, #slider > .tns-outer, + /* Everything fills the full viewport height */ + #slides-join-wrapper { + height: 100vh; + width: 100%; + } + + #slides-join-wrapper #joinScreen, + #slides-join-wrapper #slides { + height: 100vh; + } + + #slider, #slider > .tns-outer, #slider > .tns-outer > .tns-ovh, #slider > .tns-outer > .tns-ovh > .tns-inner, #slider .tns-slider, @@ -23,10 +33,9 @@ justify-content: center; } - /* Slide image fills its container exactly (container is pre-calculated to match aspect ratio) */ #slider .tns-item img { - width: 100%; - height: 100%; + max-width: 100%; + max-height: 100%; object-fit: contain; } @@ -86,7 +95,7 @@
0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-center justify-center relative bg-black"} + class={"w-full h-screen grid #{if (@state.chat_visible && @event.presentation_file.length > 0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-stretch relative bg-black"} phx-hook="Split" data-type="column" data-gutter=".gutter-1" @@ -200,11 +209,11 @@ •••
-
+
{gettext("Scan to interact in real-time")} From fcb43e7f4ef196926714e9918e7edb32d075aca5 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:20:33 -0400 Subject: [PATCH 20/67] Change join screen to overlay on top of slide instead of side panel Restore original slide layout (grid with chat + slides). Join screen is now an absolute-positioned overlay on the left 25% of the slide area with semi-transparent white background and shadow. This avoids all the proportional sizing issues of the side-by-side approach. Co-Authored-By: Claude Opus 4.6 --- .../live/event_live/presenter.html.heex | 74 +++++-------------- 1 file changed, 20 insertions(+), 54 deletions(-) diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 646c5f74..6f5688e6 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -7,37 +7,6 @@ width: 100%; height: 100%; } - - /* Everything fills the full viewport height */ - #slides-join-wrapper { - height: 100vh; - width: 100%; - } - - #slides-join-wrapper #joinScreen, - #slides-join-wrapper #slides { - height: 100vh; - } - - #slider, #slider > .tns-outer, - #slider > .tns-outer > .tns-ovh, - #slider > .tns-outer > .tns-ovh > .tns-inner, - #slider .tns-slider, - #slider .tns-item { - height: 100% !important; - } - - #slider .tns-item { - display: flex !important; - align-items: center; - justify-content: center; - } - - #slider .tns-item img { - max-width: 100%; - max-height: 100%; - object-fit: contain; - }
- <%!-- JOIN SCREEN is rendered inside the slides area as a side panel --%> <%= if @current_poll do %>
0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-stretch relative bg-black"} + class={"w-full min-h-screen grid #{if (@state.chat_visible && @event.presentation_file.length > 0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-center justify-center relative bg-black"} phx-hook="Split" data-type="column" data-gutter=".gutter-1" @@ -208,12 +176,12 @@ > •••
- -
- + +
+ <%!-- JOIN SCREEN OVERLAY — positioned over the left side of the slide --%>
{gettext("Scan to interact in real-time")} @@ -235,25 +203,23 @@ #{String.upcase(@event.code)}
- -
- <%= if @current_embed do %> - -
- <.live_component - id="embed-component" - module={ClaperWeb.EventLive.EmbedIframeComponent} - provider={@current_embed.provider} - content={@current_embed.content} - /> -
- <% end %> -
- + +
+ <.live_component + id="embed-component" + module={ClaperWeb.EventLive.EmbedIframeComponent} + provider={@current_embed.provider} + content={@current_embed.content} />
+ <% end %> +
+
From ba9801545b69d36b31861a6a7a7f8a6847fece63 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:24:24 -0400 Subject: [PATCH 21/67] Join screen as top banner, slide fills remaining viewport height Join info displayed as a horizontal bar at the top with QR code on the left and text on the right. Slides area uses flex-1 to fill the remaining viewport height below the banner. Grid uses items-stretch + h-screen so all columns fill the full viewport. Co-Authored-By: Claude Opus 4.6 --- assets/js/app.js | 8 +- .../live/event_live/presenter.html.heex | 83 ++++++++++++------- 2 files changed, 57 insertions(+), 34 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 40986559..dd71c4c1 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -572,12 +572,10 @@ Hooks.QRCode = { var qrSize; if (this.el.dataset.panel) { - // Side panel mode: size relative to panel dimensions + // Top banner mode: QR fits within the banner height var panel = this.el.closest("#joinScreen"); - var panelWidth = panel?.clientWidth || 200; - var panelHeight = panel?.clientHeight || 400; - // QR should leave room for text above and below it - qrSize = Math.min(panelWidth * 0.7, panelHeight * 0.4); + var panelHeight = panel?.clientHeight || 80; + qrSize = Math.max(panelHeight - 16, 60); } else if (this.el.dataset.dynamic) { qrSize = document.documentElement.clientWidth * 0.25; } else { diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 6f5688e6..f49422fd 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -7,6 +7,27 @@ width: 100%; height: 100%; } + + /* tiny-slider wrappers fill the available height */ + #slider, #slider > .tns-outer, + #slider > .tns-outer > .tns-ovh, + #slider > .tns-outer > .tns-ovh > .tns-inner, + #slider .tns-slider, + #slider .tns-item { + height: 100% !important; + } + + #slider .tns-item { + display: flex !important; + align-items: center; + justify-content: center; + } + + #slider .tns-item img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + }
0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-center justify-center relative bg-black"} + class={"w-full h-screen grid #{if (@state.chat_visible && @event.presentation_file.length > 0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-stretch relative bg-black"} phx-hook="Split" data-type="column" data-gutter=".gutter-1" @@ -177,15 +198,12 @@ •••
-
- <%!-- JOIN SCREEN OVERLAY — positioned over the left side of the slide --%> +
+ <%!-- JOIN SCREEN — horizontal banner at the top --%>
- - {gettext("Scan to interact in real-time")} -
- - {gettext("Or go to %{url} and use the code:", url: @host)} - - - #{String.upcase(@event.code)} - +
+ + {gettext("Scan to interact in real-time")} + + + {gettext("Or go to %{url} and use the code:", url: @host)} + + + #{String.upcase(@event.code)} + +
- <%= if @current_embed do %> - -
- <.live_component - id="embed-component" - module={ClaperWeb.EventLive.EmbedIframeComponent} - provider={@current_embed.provider} - content={@current_embed.content} + <%!-- Slide area fills remaining height --%> +
+ <%= if @current_embed do %> +
+ <.live_component + id="embed-component" + module={ClaperWeb.EventLive.EmbedIframeComponent} + provider={@current_embed.provider} + content={@current_embed.content} + /> +
+ <% end %> +
+
- <% end %> -
-
From 28b85e33da34e08370bc73a0d09a6c7fb6754ce0 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:27:09 -0400 Subject: [PATCH 22/67] Fix join banner: cap height at 120px, fixed 80px QR code The join banner no longer stretches with the viewport. QR code is a fixed 80px, banner max-height is 120px with flex-grow-0. Co-Authored-By: Claude Opus 4.6 --- assets/js/app.js | 6 ++---- lib/claper_web/live/event_live/presenter.html.heex | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index dd71c4c1..9abde2ab 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -572,10 +572,8 @@ Hooks.QRCode = { var qrSize; if (this.el.dataset.panel) { - // Top banner mode: QR fits within the banner height - var panel = this.el.closest("#joinScreen"); - var panelHeight = panel?.clientHeight || 80; - qrSize = Math.max(panelHeight - 16, 60); + // Top banner mode: fixed small QR code + qrSize = 80; } else if (this.el.dataset.dynamic) { qrSize = document.documentElement.clientWidth * 0.25; } else { diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index f49422fd..cd9d5694 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -202,7 +202,8 @@ <%!-- JOIN SCREEN — horizontal banner at the top --%>
Date: Tue, 7 Apr 2026 11:33:09 -0400 Subject: [PATCH 23/67] Restore original slide layout, join banner as absolute overlay at top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverted grid and slide structure to match the original codebase exactly. Join screen is now an absolute-positioned banner at the top of the slide area that floats over the slide without affecting layout. Removed URL text from the banner — only shows QR code and event code. Removed all custom tns CSS overrides. Co-Authored-By: Claude Opus 4.6 --- .../live/event_live/presenter.html.heex | 67 ++++++------------- 1 file changed, 20 insertions(+), 47 deletions(-) diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index cd9d5694..57f4da37 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -7,27 +7,6 @@ width: 100%; height: 100%; } - - /* tiny-slider wrappers fill the available height */ - #slider, #slider > .tns-outer, - #slider > .tns-outer > .tns-ovh, - #slider > .tns-outer > .tns-ovh > .tns-inner, - #slider .tns-slider, - #slider .tns-item { - height: 100% !important; - } - - #slider .tns-item { - display: flex !important; - align-items: center; - justify-content: center; - } - - #slider .tns-item img { - max-width: 100%; - max-height: 100%; - object-fit: contain; - }
0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-stretch relative bg-black"} + class={"w-full min-h-screen grid #{if (@state.chat_visible && @event.presentation_file.length > 0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-center justify-center relative bg-black"} phx-hook="Split" data-type="column" data-gutter=".gutter-1" @@ -198,12 +177,11 @@ •••
-
- <%!-- JOIN SCREEN — horizontal banner at the top --%> +
+ <%!-- JOIN SCREEN — banner pinned to the top of the slide area --%>
{gettext("Scan to interact in real-time")} - - {gettext("Or go to %{url} and use the code:", url: @host)} - - + #{String.upcase(@event.code)}
- <%!-- Slide area fills remaining height --%> -
- <%= if @current_embed do %> -
- <.live_component - id="embed-component" - module={ClaperWeb.EventLive.EmbedIframeComponent} - provider={@current_embed.provider} - content={@current_embed.content} - /> -
- <% end %> -
- + +
+ <.live_component + id="embed-component" + module={ClaperWeb.EventLive.EmbedIframeComponent} + provider={@current_embed.provider} + content={@current_embed.content} />
+ <% end %> +
+
From b329e99a36c9c15e6b1074e2323dfa0484f2bb95 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:36:28 -0400 Subject: [PATCH 24/67] Join banner in flow above slide, chat matches slide height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Join screen is a flex-shrink-0 banner in normal document flow above the slide — no overlay. Slide area uses flex-1 to fill remaining height. Grid uses items-stretch so chat column matches the slides column height. CSS propagates height through all tns wrappers. Co-Authored-By: Claude Opus 4.6 --- .../live/event_live/presenter.html.heex | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 57f4da37..81510550 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -1,12 +1,36 @@
0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-center justify-center relative bg-black"} + class={"w-full h-screen grid #{if (@state.chat_visible && @event.presentation_file.length > 0) || (@current_embed && @event.presentation_file.length == 0), do: "grid-cols-[1fr_10px_1fr]", else: "grid-cols-[1fr]"} items-stretch relative bg-black overflow-hidden"} phx-hook="Split" data-type="column" data-gutter=".gutter-1" @@ -177,11 +201,11 @@ •••
-
- <%!-- JOIN SCREEN — banner pinned to the top of the slide area --%> +
+ <%!-- JOIN SCREEN — banner above the slide --%>
- <%= if @current_embed do %> - -
- <.live_component - id="embed-component" - module={ClaperWeb.EventLive.EmbedIframeComponent} - provider={@current_embed.provider} - content={@current_embed.content} + <%!-- Slide fills remaining space --%> +
+ <%= if @current_embed do %> +
+ <.live_component + id="embed-component" + module={ClaperWeb.EventLive.EmbedIframeComponent} + provider={@current_embed.provider} + content={@current_embed.content} + /> +
+ <% end %> +
+
- <% end %> -
-
From d4db3d9f6a5bfeb74ed902386b34108a994de030 Mon Sep 17 00:00:00 2001 From: aplicacionesitgpc <63760707+aplicacionesitgpc@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:49:18 -0400 Subject: [PATCH 25/67] Change presenter background from black to light gray Replaces the high-contrast black background with light gray (#f3f4f6) so empty space around slides blends with typical white/light slide backgrounds, matching Google Slides' presenter mode approach. Co-Authored-By: Claude Opus 4.6 --- lib/claper_web/live/event_live/presenter.html.heex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/claper_web/live/event_live/presenter.html.heex b/lib/claper_web/live/event_live/presenter.html.heex index 81510550..17aefc8f 100644 --- a/lib/claper_web/live/event_live/presenter.html.heex +++ b/lib/claper_web/live/event_live/presenter.html.heex @@ -1,6 +1,6 @@ <%= case @provider do %> <% "youtube" -> %>