diff --git a/lib/claper/presentations.ex b/lib/claper/presentations.ex
index c73361c..52840ff 100644
--- a/lib/claper/presentations.ex
+++ b/lib/claper/presentations.ex
@@ -117,6 +117,41 @@ defmodule Claper.Presentations do
end
end
+ @doc """
+ Returns a list of thumbnail URLs for a given presentation.
+ Thumbnails are smaller versions of slides stored in a 'thumbs' subdirectory.
+ """
+ def get_slide_thumbnail_urls(nil), do: []
+
+ def get_slide_thumbnail_urls(%PresentationFile{hash: nil}), 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)
+ end
+
+ def get_slide_thumbnail_urls(hash, length) 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
+ "/uploads/#{hash}/thumbs/#{index}.jpg"
+ end
+
+ "s3" ->
+ base_url = Keyword.fetch!(config, :s3_public_url)
+
+ for index <- 1..length do
+ base_url <> "/presentations/#{hash}/thumbs/#{index}.jpg"
+ end
+
+ storage ->
+ raise "Unrecognised presentations storage value #{storage}"
+ end
+ end
+
@doc """
Creates a presentation_files.
diff --git a/lib/claper/tasks/converter.ex b/lib/claper/tasks/converter.ex
index ff8baf7..ea2d8df 100644
--- a/lib/claper/tasks/converter.ex
+++ b/lib/claper/tasks/converter.ex
@@ -92,25 +92,62 @@ defmodule Claper.Tasks.Converter do
defp pdf_to_jpg(%Result{status: 0}, path, _presentation, _user_id) do
resolution = get_resolution()
- Porcelain.exec(
- "gs",
- [
- "-sDEVICE=png16m",
- "-o#{path}/%d.jpg",
- "-r#{resolution}",
- "-dNOPAUSE",
- "-dBATCH",
- "#{path}/original.pdf"
- ]
- )
+ result =
+ Porcelain.exec(
+ "gs",
+ [
+ "-sDEVICE=png16m",
+ "-o#{path}/%d.jpg",
+ "-r#{resolution}",
+ "-dNOPAUSE",
+ "-dBATCH",
+ "#{path}/original.pdf"
+ ]
+ )
+
+ # Generate thumbnails after full-size images
+ case result do
+ %Porcelain.Result{status: 0} -> generate_thumbnails(path)
+ _ -> result
+ end
+
+ result
end
defp pdf_to_jpg(_result, path, presentation, user_id) do
failure(presentation, path, user_id)
end
+ defp generate_thumbnails(path) do
+ thumbs_dir = Path.join(path, "thumbs")
+ File.mkdir_p!(thumbs_dir)
+
+ files = Path.wildcard("#{path}/*.jpg")
+
+ for file <- files do
+ thumb_path = Path.join(thumbs_dir, Path.basename(file))
+
+ # Generate thumbnail with 200px width, maintaining aspect ratio
+ # Using "magick" for ImageMagick v7+ compatibility
+ Porcelain.exec(
+ "magick",
+ [
+ file,
+ "-resize",
+ "200x",
+ "-quality",
+ "80",
+ thumb_path
+ ]
+ )
+ end
+
+ IO.puts("Generated #{length(files)} thumbnails in #{thumbs_dir}")
+ end
+
defp jpg_upload(%Result{status: 0}, hash, path, presentation, user_id, is_copy) do
files = Path.wildcard("#{path}/*.jpg")
+ thumb_files = Path.wildcard("#{path}/thumbs/*.jpg")
# assign new hash to avoid cache issues
new_hash = :erlang.phash2("#{hash}-#{System.system_time(:second)}")
@@ -129,6 +166,7 @@ defmodule Claper.Tasks.Converter do
])
)
else
+ # Upload full-size images
for f <- files do
IO.puts("Uploads #{f} to presentations/#{new_hash}/#{Path.basename(f)}")
@@ -141,6 +179,20 @@ defmodule Claper.Tasks.Converter do
)
|> ExAws.request()
end
+
+ # Upload thumbnails
+ for f <- thumb_files do
+ IO.puts("Uploads thumbnail #{f} to presentations/#{new_hash}/thumbs/#{Path.basename(f)}")
+
+ f
+ |> ExAws.S3.Upload.stream_file()
+ |> ExAws.S3.upload(
+ get_s3_bucket(),
+ "presentations/#{new_hash}/thumbs/#{Path.basename(f)}",
+ acl: "public-read"
+ )
+ |> ExAws.request()
+ end
end
if !is_nil(presentation.hash) && !is_copy do
diff --git a/lib/claper_web/live/event_live/manage.html.heex b/lib/claper_web/live/event_live/manage.html.heex
index 07303b1..532effe 100644
--- a/lib/claper_web/live/event_live/manage.html.heex
+++ b/lib/claper_web/live/event_live/manage.html.heex
@@ -5,7 +5,9 @@
phx-hook="Manager"
data-max-page={@event.presentation_file.length}
data-current-page={@state.position}
+ class="h-screen flex flex-col overflow-hidden bg-gray-50"
>
+
-
+
+
+
+
@@ -104,7 +107,6 @@
>
-
-
{gettext("Open preview")}
{gettext("Close preview")}
@@ -134,6 +135,8 @@
+
+
-
- {gettext("Poll")}
-
-
- {gettext("Add poll to know opinion of your public.")}
-
+
{gettext("Poll")}
+
{gettext("Add poll to know opinion of your public.")}
@@ -223,12 +222,8 @@
-
- {gettext("Form")}
-
-
- {gettext("Add form to collect data from your public.")}
-
+
{gettext("Form")}
+
{gettext("Add form to collect data from your public.")}
@@ -257,9 +252,7 @@
{gettext("Web content")}
-
- {gettext("Add a Youtube video or any web content.")}
-
+
{gettext("Add a Youtube video or any web content.")}
@@ -281,17 +274,14 @@
>
{gettext("Quiz")}
-
- {gettext("Add a quiz to test knowledge.")}
-
+
{gettext("Add a quiz to test knowledge.")}
@@ -317,7 +307,6 @@
/>
<% end %>
-
<%= if @create=="form" do %>
<% end %>
-
<%= if @create == "embed" do %>
<% end %>
-
<%= if @create=="quiz" do %>
<% end %>
-
<%= if @create == "import" do %>
+
+
+
+
-
-
-
-
-
-
-
- {@event.name}
-
-
-
-
-
-
- {@attendees_nb}
-
-
- {link(gettext("Join"),
- to: ~p"/e/#{@event.code}",
- class: "text-xs text-primary-600 font-semibold text-sm ",
- target: "_blank"
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
0, do: "Split", else: ""}"}
- data-type="row"
- data-gutter=".gutter"
- id="base-layout"
- class={"#{if @event.presentation_file.length > 0, do: "md:grid grid-rows-[0.3fr_10px_1fr] overflow-y-auto", else: ""}"}
+
+
+
+
+
+
0}
- id="slides-layout"
- class="flex overflow-x-auto w-full md:h-full"
+ class="flex-shrink-0 h-1/3 border-b border-gray-200"
>
-
+ <.live_component
+ id="slide-preview"
+ module={ClaperWeb.EventLive.ManageSlidePreviewComponent}
+ presentation_file={@event.presentation_file}
+ current_position={@state.position}
+ total_slides={@event.presentation_file.length}
+ />
- 0}
- class="hidden md:block gutter col-span-full cursor-row-resize z-20 row-2 bg-gray-50 text-center text-gray-300 text-sm leading-3"
- >
- •••
-
-
+
+
+
+
#{gettext("This section contains all your interactions.")}
#{gettext("You can add interactions to your presentation slides.")}
"}
+ class="lg:w-1/3 flex-shrink-0 p-4 overflow-y-auto border-b lg:border-b-0 lg:border-r border-gray-200"
+ data-tg-order="2"
+ data-tg-title={gettext("Your interactions")}
+ data-tg-tour={"
#{gettext("This section contains all your interactions for the current slide.")}
"}
data-tg-group="manage"
>
-
-
-
-
+ <.live_component
+ id="interaction-list"
+ module={ClaperWeb.EventLive.ManageInteractionListComponent}
+ interactions={@interactions}
+ event_code={@event.code}
+ />
+
-
- 0}>
- {gettext("This slide does not have any interactions.")}
-
-
- {gettext("Create your first interaction.")}
-
-
-
-