diff --git a/lib/phoenix_kit_web/helpers/media_selector_helper.ex b/lib/phoenix_kit_web/helpers/media_selector_helper.ex new file mode 100644 index 000000000..cded2858a --- /dev/null +++ b/lib/phoenix_kit_web/helpers/media_selector_helper.ex @@ -0,0 +1,146 @@ +defmodule PhoenixKitWeb.Helpers.MediaSelectorHelper do + @moduledoc """ + Helper functions for integrating the media selector component. + + Provides convenience functions to generate media selector URLs, + parse returned selections, and handle media selector integration + from any LiveView or controller. + + ## Examples + + # In a LiveView template + <.link navigate={MediaSelectorHelper.media_selector_url(@current_path, mode: :single)}> + Select Featured Image + + + # In handle_params to receive selection + def handle_params(params, _uri, socket) do + case MediaSelectorHelper.parse_selected_media(params) do + {:ok, [file_id]} -> + socket + |> assign(:featured_image_id, file_id) + |> put_flash(:info, "Image selected!") + + {:ok, file_ids} -> + socket + |> assign(:gallery_ids, file_ids) + |> put_flash(:info, "\#{length(file_ids)} images selected!") + + :none -> + socket + end + end + """ + + alias PhoenixKit.Utils.Routes + + @doc """ + Generates a URL to the media selector page. + + ## Parameters + + - `return_to` - The URL to return to after selection (required) + - `opts` - Keyword list of options: + - `:mode` - Selection mode: `:single` or `:multiple` (default: `:single`) + - `:filter` - File type filter: `:image`, `:video`, or `:all` (default: `:all`) + - `:selected` - List of pre-selected file IDs (optional) + + ## Examples + + # Single image selection + media_selector_url("/admin/blog/edit", mode: :single, filter: :image) + #=> "/admin/media/selector?return_to=%2Fadmin%2Fblog%2Fedit&mode=single&filter=image" + + # Multiple selection with pre-selected files + media_selector_url("/admin/gallery", mode: :multiple, selected: ["id1", "id2"]) + #=> "/admin/media/selector?return_to=%2Fadmin%2Fgallery&mode=multiple&selected=id1%2Cid2" + + """ + def media_selector_url(return_to, opts \\ []) do + mode = Keyword.get(opts, :mode, :single) + filter = Keyword.get(opts, :filter, :all) + selected = Keyword.get(opts, :selected, []) + + base_url = Routes.path("/admin/media/selector") + params = build_query_params(return_to, mode, filter, selected) + + "#{base_url}?#{params}" + end + + @doc """ + Parses the selected media from LiveView params. + + Returns `{:ok, [file_ids]}` if media was selected, or `:none` if no selection. + + ## Examples + + parse_selected_media(%{"selected_media" => "id1,id2,id3"}) + #=> {:ok, ["id1", "id2", "id3"]} + + parse_selected_media(%{"selected_media" => "single-id"}) + #=> {:ok, ["single-id"]} + + parse_selected_media(%{}) + #=> :none + + """ + def parse_selected_media(%{"selected_media" => selected_media}) + when is_binary(selected_media) do + file_ids = + selected_media + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + case file_ids do + [] -> :none + ids -> {:ok, ids} + end + end + + def parse_selected_media(_params), do: :none + + @doc """ + Extracts the first file ID from selected media. + + Useful for single-selection scenarios where you only need one file ID. + + ## Examples + + get_first_selected(%{"selected_media" => "id1,id2,id3"}) + #=> {:ok, "id1"} + + get_first_selected(%{"selected_media" => "single-id"}) + #=> {:ok, "single-id"} + + get_first_selected(%{}) + #=> :none + + """ + def get_first_selected(params) do + case parse_selected_media(params) do + {:ok, [first_id | _]} -> {:ok, first_id} + :none -> :none + end + end + + # Private Functions + + defp build_query_params(return_to, mode, filter, selected) do + params = [ + {"return_to", return_to}, + {"mode", to_string(mode)}, + {"filter", to_string(filter)} + ] + + params = + if Enum.any?(selected) do + selected_string = Enum.join(selected, ",") + params ++ [{"selected", selected_string}] + else + params + end + + URI.encode_query(params) + end +end diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index 92630fcf2..611f69ec4 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -286,6 +286,7 @@ defmodule PhoenixKitWeb.Integration do live "/admin/users/sessions", Live.Users.Sessions, :index live "/admin/users/media", Live.Users.Media, :index live "/admin/users/media/:file_id", Live.Users.MediaDetail, :show + live "/admin/media/selector", Live.Users.MediaSelector, :index live "/admin/settings", Live.Settings, :index live "/admin/settings/users", Live.Settings.Users, :index live "/admin/modules", Live.Modules, :index @@ -417,6 +418,7 @@ defmodule PhoenixKitWeb.Integration do live "/admin/users/sessions", Live.Users.Sessions, :index live "/admin/users/media", Live.Users.Media, :index live "/admin/users/media/:file_id", Live.Users.MediaDetail, :show + live "/admin/media/selector", Live.Users.MediaSelector, :index live "/admin/settings", Live.Settings, :index live "/admin/settings/users", Live.Settings.Users, :index live "/admin/modules", Live.Modules, :index diff --git a/lib/phoenix_kit_web/live/components/media_selector_modal.ex b/lib/phoenix_kit_web/live/components/media_selector_modal.ex new file mode 100644 index 000000000..81f6fa1ae --- /dev/null +++ b/lib/phoenix_kit_web/live/components/media_selector_modal.ex @@ -0,0 +1,407 @@ +defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do + @moduledoc """ + Media selector modal component. + + A reusable modal component for selecting media files from anywhere in the admin panel. + Supports both single and multiple selection modes. + + ## Usage + + # In parent LiveView, add to socket assigns + socket + |> assign(:show_media_selector, false) + |> assign(:media_selection_mode, :single) + |> assign(:media_selected_ids, []) + + # In template + <.live_component + module={PhoenixKitWeb.Live.Components.MediaSelectorModal} + id="media-selector-modal" + show={@show_media_selector} + mode={@media_selection_mode} + selected_ids={@media_selected_ids} + /> + + # To open the modal + def handle_event("open_media_selector", _params, socket) do + {:noreply, assign(socket, :show_media_selector, true)} + end + + # To receive selected media + def handle_info({:media_selected, file_ids}, socket) do + # Handle the selected file IDs + {:noreply, socket |> assign(:gallery_ids, file_ids)} + end + """ + use PhoenixKitWeb, :live_component + + require Logger + + alias PhoenixKit.Storage + alias PhoenixKit.Storage.{File, FileInstance, URLSigner} + alias PhoenixKit.Users.Auth + + import Ecto.Query + + # Import core components + import PhoenixKitWeb.Components.Core.Icon + + @per_page 30 + + def update(assigns, socket) do + socket = + socket + |> assign(assigns) + |> assign_new(:selected_ids, fn -> MapSet.new(assigns[:selected_ids] || []) end) + |> assign_new(:file_type_filter, fn -> :all end) + |> assign_new(:search_query, fn -> "" end) + |> assign_new(:current_page, fn -> 1 end) + |> assign_new(:per_page, fn -> @per_page end) + |> assign_new(:uploaded_files, fn -> [] end) + |> assign_new(:total_count, fn -> 0 end) + |> assign_new(:total_pages, fn -> 0 end) + |> maybe_allow_upload() + + # Load files if modal is shown + socket = + if assigns[:show] do + {files, total_count} = load_files(socket, socket.assigns.current_page) + total_pages = ceil(total_count / socket.assigns.per_page) + + socket + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + else + socket + end + + {:ok, socket} + end + + defp maybe_allow_upload(socket) do + if socket.assigns[:uploads] do + socket + else + allow_upload(socket, :media_files, + accept: :any, + max_entries: 10, + auto_upload: true, + progress: &handle_progress/3 + ) + end + end + + def handle_event("noop", _params, socket) do + # No-op event to prevent click propagation + {:noreply, socket} + end + + def handle_event("toggle_selection", %{"file-id" => file_id}, socket) do + selected_ids = socket.assigns.selected_ids + mode = socket.assigns.mode + + new_selected_ids = + case mode do + :single -> + MapSet.new([file_id]) + + :multiple -> + if MapSet.member?(selected_ids, file_id) do + MapSet.delete(selected_ids, file_id) + else + MapSet.put(selected_ids, file_id) + end + end + + {:noreply, assign(socket, :selected_ids, new_selected_ids)} + end + + def handle_event("confirm_selection", _params, socket) do + selected_ids = socket.assigns.selected_ids |> MapSet.to_list() + + # Send selected IDs to parent LiveView + send(self(), {:media_selected, selected_ids}) + + # Close modal + {:noreply, assign(socket, :show, false)} + end + + def handle_event("close_modal", _params, socket) do + send(self(), {:media_selector_closed}) + {:noreply, assign(socket, :show, false)} + end + + def handle_event("search", %{"search" => %{"query" => query}}, socket) do + socket = + socket + |> assign(:search_query, query) + |> assign(:current_page, 1) + + {files, total_count} = load_files(socket, 1) + total_pages = ceil(total_count / socket.assigns.per_page) + + socket = + socket + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + + {:noreply, socket} + end + + def handle_event("filter_type", %{"filter" => filter}, socket) do + parsed_filter = parse_filter(filter) + + socket = + socket + |> assign(:file_type_filter, parsed_filter) + |> assign(:current_page, 1) + + {files, total_count} = load_files(socket, 1) + total_pages = ceil(total_count / socket.assigns.per_page) + + socket = + socket + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + + {:noreply, socket} + end + + def handle_event("change_page", %{"page" => page}, socket) do + page = String.to_integer(page) + {files, total_count} = load_files(socket, page) + total_pages = ceil(total_count / socket.assigns.per_page) + + socket = + socket + |> assign(:current_page, page) + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + + {:noreply, socket} + end + + def handle_event("cancel_upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :media_files, ref)} + end + + def handle_event("validate", _params, socket) do + {:noreply, socket} + end + + def handle_event("save", _params, socket) do + {files, total_count} = load_files(socket, 1) + total_pages = ceil(total_count / socket.assigns.per_page) + + socket = + socket + |> assign(:current_page, 1) + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + + {:noreply, socket} + end + + defp handle_progress(:media_files, entry, socket) do + socket = + if entry.done? do + # Consume the uploaded entry and capture the file ID + uploaded_results = + consume_uploaded_entry(socket, entry, fn %{path: path} -> + process_upload(socket, path, entry) + end) + + # Extract the file ID from the result - consume_uploaded_entry returns [{:ok, file_id}] + new_file_id = + case uploaded_results do + [{:ok, file_id}] when is_binary(file_id) -> file_id + _ -> nil + end + + # Reload files to show the newly uploaded file + {files, total_count} = load_files(socket, socket.assigns.current_page) + total_pages = ceil(total_count / socket.assigns.per_page) + + # Auto-select the newly uploaded file + selected_ids = + if new_file_id do + case socket.assigns.mode do + :single -> MapSet.new([new_file_id]) + :multiple -> MapSet.put(socket.assigns.selected_ids, new_file_id) + end + else + socket.assigns.selected_ids + end + + socket + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + |> assign(:selected_ids, selected_ids) + else + socket + end + + {:noreply, socket} + end + + defp process_upload(socket, path, entry) do + ext = Path.extname(entry.client_name) |> String.replace_leading(".", "") + mime_type = entry.client_type || MIME.from_path(entry.client_name) + file_type = determine_file_type(mime_type) + + current_user = socket.assigns[:phoenix_kit_current_user] + + if current_user do + user_id = current_user.id + file_hash = Auth.calculate_file_hash(path) + + case Storage.store_file_in_buckets( + path, + file_type, + user_id, + file_hash, + ext, + entry.client_name + ) do + {:ok, file, :duplicate} -> + Logger.info("Duplicate file uploaded: #{file.id}") + {:ok, file.id} + + {:ok, file} -> + Logger.info("New file uploaded: #{file.id}") + {:ok, file.id} + + {:error, reason} -> + Logger.error("Upload failed: #{inspect(reason)}") + {:postpone, :error} + end + else + Logger.error("Upload failed: No authenticated user") + {:postpone, :error} + end + end + + defp load_files(socket, page) do + repo = Application.get_env(:phoenix_kit, :repo) + per_page = socket.assigns.per_page + filter = socket.assigns.file_type_filter + search = socket.assigns.search_query + + query = from(f in File, order_by: [desc: f.inserted_at]) + + query = + case filter do + :image -> where(query, [f], f.file_type == "image") + :video -> where(query, [f], f.file_type == "video") + :all -> query + end + + query = + if search != "" do + search_pattern = "%#{search}%" + where(query, [f], ilike(f.original_file_name, ^search_pattern)) + else + query + end + + total_count = repo.aggregate(query, :count, :id) + offset = (page - 1) * per_page + + files = + query + |> limit(^per_page) + |> offset(^offset) + |> repo.all() + + file_ids = Enum.map(files, & &1.id) + + instances_by_file = + if Enum.any?(file_ids) do + from(fi in FileInstance, where: fi.file_id in ^file_ids) + |> repo.all() + |> Enum.group_by(& &1.file_id) + else + %{} + end + + files_with_urls = + Enum.map(files, fn file -> + instances = Map.get(instances_by_file, file.id, []) + urls = generate_urls_from_instances(instances, file.id) + + %{ + file_id: file.id, + filename: file.original_file_name || file.file_name || "Unknown", + file_type: file.file_type, + mime_type: file.mime_type, + size: file.size || 0, + urls: urls, + width: get_dimension_from_instances(instances, :width), + height: get_dimension_from_instances(instances, :height) + } + end) + + {files_with_urls, total_count} + end + + defp generate_urls_from_instances(instances, file_id) do + Enum.reduce(instances, %{}, fn instance, acc -> + url = URLSigner.signed_url(file_id, instance.variant_name) + Map.put(acc, instance.variant_name, url) + end) + end + + defp get_dimension_from_instances(instances, field) do + case Enum.find(instances, &(&1.variant_name == "original")) do + nil -> nil + instance -> Map.get(instance, field) + end + end + + defp determine_file_type(mime_type) do + cond do + String.starts_with?(mime_type, "image/") -> "image" + String.starts_with?(mime_type, "video/") -> "video" + true -> "other" + end + end + + defp parse_filter(nil), do: :all + defp parse_filter("image"), do: :image + defp parse_filter("video"), do: :video + defp parse_filter("all"), do: :all + defp parse_filter(_), do: :all + + defp format_file_size(bytes) when is_number(bytes) do + cond do + bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 2)} GB" + bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 2)} MB" + bytes >= 1024 -> "#{Float.round(bytes / 1024, 2)} KB" + true -> "#{bytes} B" + end + end + + defp format_file_size(_), do: "0 B" + + defp pagination_range(current_page, total_pages) do + cond do + total_pages <= 7 -> + Enum.to_list(1..total_pages) + + current_page <= 4 -> + [1, 2, 3, 4, 5, :ellipsis, total_pages] + + current_page >= total_pages - 3 -> + [1, :ellipsis | Enum.to_list((total_pages - 4)..total_pages)] + + true -> + [1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages] + end + end +end diff --git a/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex b/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex new file mode 100644 index 000000000..f44909140 --- /dev/null +++ b/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex @@ -0,0 +1,296 @@ +<%!-- Media Selector Modal Overlay --%> +
+ <%!-- Modal Container --%> +
+ <%!-- Modal Content (click inside shouldn't close) --%> +
+ <%!-- Header with Actions --%> +
+
+
+

+ Select Media +

+

+ <%= if @mode == :single do %> + Click on an image to select it + <% else %> + Select one or more images + <% end %> + <%= if MapSet.size(@selected_ids) > 0 do %> + + {MapSet.size(@selected_ids)} selected + + <% end %> +

+
+
+ + +
+
+
+ <%!-- Scrollable Content Area --%> +
+ <%!-- Upload Section --%> +
+
+

+ <.icon name="hero-arrow-up-tray" class="w-4 h-4" /> Upload New Files +

+
+ <%!-- Drag and Drop Zone --%> +
+ + <.live_file_input upload={@uploads.media_files} class="hidden" /> +
+ + <%!-- Active Uploads --%> + <%= if length(@uploads.media_files.entries) > 0 do %> +
+ <%= for entry <- @uploads.media_files.entries do %> +
+
+

{entry.client_name}

+
+ + {entry.progress}% + + + {entry.progress}% + +
+
+ <%!-- Cancel Button --%> + +
+ <% end %> +
+ <% end %> +
+
+
+ <%!-- Search and Filter Section --%> +
+
+
+ <%!-- Search --%> +
+ <.form for={%{}} phx-submit="search" phx-target={@myself} class="w-full"> +
+ + +
+ +
+ <%!-- Filter by Type --%> +
+ +
+
+
+
+ <%!-- Media Grid --%> + <%= if Enum.empty?(@uploaded_files) do %> +
+ <.icon name="hero-photo" class="w-12 h-12 mx-auto mb-3 text-base-content/30" /> +

No media files found

+

+ Upload files using the area above or adjust your search filters +

+
+ <% else %> +
+ <%= for file <- @uploaded_files do %> + <% is_selected = MapSet.member?(@selected_ids, file.file_id) %> +
+ <%!-- Thumbnail --%> +
+ <%= if file.file_type == "image" do %> + {file.filename} + <% else %> +
+ <%= if file.file_type == "video" do %> + <.icon name="hero-video-camera" class="w-12 h-12 text-base-content/30" /> + <% else %> + <.icon name="hero-document" class="w-12 h-12 text-base-content/30" /> + <% end %> +
+ <% end %> + <%!-- Selection Indicator --%> + <%= if is_selected do %> +
+ <.icon name="hero-check" class="w-4 h-4" /> +
+ <% else %> +
+
+ <% end %> + <%!-- File Type Badge --%> +
+ + {file.file_type} + +
+
+ <%!-- File Info --%> +
+

+ {file.filename} +

+
+ {format_file_size(file.size)} + <%= if file.width && file.height do %> + {file.width}×{file.height} + <% end %> +
+
+
+ <% end %> +
+ <% end %> + <%!-- Pagination --%> + <%= if @total_pages > 1 do %> +
+
+ <%= if @current_page > 1 do %> + + <% else %> + + <% end %> + + <%= for page <- pagination_range(@current_page, @total_pages) do %> + <%= if page == :ellipsis do %> + + <% else %> + + <% end %> + <% end %> + + <%= if @current_page < @total_pages do %> + + <% else %> + + <% end %> +
+
+ <% end %> +
+
+
+
diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.ex b/lib/phoenix_kit_web/live/modules/blogging/editor.ex index fc0de8705..3c4515275 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.ex @@ -27,6 +27,9 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> assign(:project_title, Settings.get_setting("project_title", "PhoenixKit")) |> assign(:page_title, "Blogging Editor") |> assign(:blog_slug, blog_slug) + |> assign(:show_media_selector, false) + |> assign(:media_selection_mode, :single) + |> assign(:media_selected_ids, MapSet.new()) {:ok, socket} end @@ -72,7 +75,9 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> assign(:all_enabled_languages, all_enabled_languages) |> assign( :current_path, - Routes.path("/admin/blogging/#{blog_slug}/edit", locale: socket.assigns.current_locale) + Routes.path("/admin/blogging/#{blog_slug}/edit?new=true", + locale: socket.assigns.current_locale + ) ) |> assign(:has_pending_changes, false) |> assign(:is_new_post, true) @@ -120,7 +125,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> assign(:all_enabled_languages, all_enabled_languages) |> assign( :current_path, - Routes.path("/admin/blogging/#{blog_slug}/edit", + Routes.path( + "/admin/blogging/#{blog_slug}/edit?path=#{URI.encode_www_form(new_path)}", locale: socket.assigns.current_locale ) ) @@ -141,7 +147,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> assign(:all_enabled_languages, all_enabled_languages) |> assign( :current_path, - Routes.path("/admin/blogging/#{blog_slug}/edit", + Routes.path("/admin/blogging/#{blog_slug}/edit?path=#{URI.encode_www_form(path)}", locale: socket.assigns.current_locale ) ) @@ -162,6 +168,11 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do end end + # Catch-all for other param combinations (shouldn't normally be reached) + def handle_params(_params, _uri, socket) do + {:noreply, socket} + end + @impl true def handle_event("update_meta", params, socket) do params = @@ -194,6 +205,22 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> push_event("changes-status", %{has_changes: has_changes})} end + def handle_event("open_media_selector", _params, socket) do + {:noreply, assign(socket, :show_media_selector, true)} + end + + def handle_event("clear_featured_image", _params, socket) do + # Clear the featured image from the form (form is a simple map, not a struct) + updated_form = Map.put(socket.assigns.form, "featured_image_id", "") + + {:noreply, + socket + |> assign(:form, updated_form) + |> assign(:has_pending_changes, true) + |> put_flash(:info, gettext("Featured image cleared")) + |> push_event("changes-status", %{has_changes: true})} + end + def handle_event("generate_slug_from_content", _params, socket) do # Only generate slug if in slug mode and slug is empty if socket.assigns.blog_mode == "slug" do @@ -404,6 +431,31 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do end end + @impl true + def handle_info({:media_selected, file_ids}, socket) do + # Handle the selected file IDs from the media selector modal + file_id = List.first(file_ids) + + socket = + if file_id do + socket + |> assign(:form, update_form_with_media(socket.assigns.form, file_id)) + |> assign(:has_pending_changes, true) + |> assign(:show_media_selector, false) + |> put_flash(:info, gettext("Featured image selected")) + |> push_event("changes-status", %{has_changes: true}) + else + socket + |> assign(:show_media_selector, false) + end + + {:noreply, socket} + end + + def handle_info({:media_selector_closed}, socket) do + {:noreply, assign(socket, :show_media_selector, false)} + end + defp create_new_post(socket, params) do scope = socket.assigns[:phoenix_kit_current_scope] @@ -1161,4 +1213,10 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> Enum.drop(1) |> Enum.join("/") end + + defp update_form_with_media(form, file_id) do + # Update the form with the selected file_id + # The form is a simple map with string keys + Map.put(form, "featured_image_id", file_id) + end end diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex index 3a8de4c3d..83e515387 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex @@ -195,29 +195,47 @@ {gettext("Featured image file ID")} - -

- - {gettext("Use the Media Manager to upload or copy a file ID.")} - - +

+ + <%= if @form["featured_image_id"] && @form["featured_image_id"] != "" do %> + + <% end %> +
+ + <%!-- Manual Input (Advanced) --%> +
+ + {gettext("Advanced: Enter File ID Manually")} + +
+ +

+ {gettext("Enter a file ID directly if you know it.")} +

+
+
<%= if preview_url = featured_image_preview_url(@form["featured_image_id"]) do %>
+ + <%!-- Media Selector Modal --%> + <.live_component + module={PhoenixKitWeb.Live.Components.MediaSelectorModal} + id="media-selector-modal" + show={@show_media_selector} + mode={@media_selection_mode} + selected_ids={@media_selected_ids} + phoenix_kit_current_user={assigns[:phoenix_kit_current_user]} + /> diff --git a/lib/phoenix_kit_web/live/users/media_selector.ex b/lib/phoenix_kit_web/live/users/media_selector.ex new file mode 100644 index 000000000..ce7130a28 --- /dev/null +++ b/lib/phoenix_kit_web/live/users/media_selector.ex @@ -0,0 +1,395 @@ +defmodule PhoenixKitWeb.Live.Users.MediaSelector do + @moduledoc """ + Generic media selector LiveView. + + Provides a reusable interface for selecting media files from anywhere in the admin panel. + Supports both single and multiple selection modes. + + ## Usage + + # Navigate to selector with query params + /admin/media/selector?return_to=/admin/blogging/edit&mode=single&filter=image + + ## Query Parameters + + - `return_to` - URL to navigate back to (required) + - `mode` - "single" or "multiple" (default: "single") + - `selected` - Comma-separated pre-selected file IDs (optional) + - `filter` - "image", "video", "all" (default: "all") + - `page` - Page number for pagination (default: "1") + """ + use PhoenixKitWeb, :live_view + + require Logger + + alias PhoenixKit.Settings + alias PhoenixKit.Storage + alias PhoenixKit.Storage.{File, FileInstance, URLSigner} + alias PhoenixKit.Users.Auth + alias PhoenixKit.Utils.Routes + + import Ecto.Query + + @per_page 30 + + def mount(params, _session, socket) do + # Handle locale + locale = params["locale"] || socket.assigns[:current_locale] || "en" + Gettext.put_locale(PhoenixKitWeb.Gettext, locale) + Process.put(:phoenix_kit_current_locale, locale) + + # Get project title + project_title = Settings.get_setting("project_title", "PhoenixKit") + + # Parse query parameters + return_to = params["return_to"] || "/" + mode = parse_mode(params["mode"]) + selected_ids = parse_selected_ids(params["selected"]) + filter = parse_filter(params["filter"]) + page = String.to_integer(params["page"] || "1") + + # Allow file uploads + socket = + socket + |> assign(:current_locale, locale) + |> assign(:current_path, Routes.path("/admin/media/selector")) + |> assign(:project_title, project_title) + |> assign(:page_title, "Select Media") + |> assign(:return_to, return_to) + |> assign(:selection_mode, mode) + |> assign(:selected_ids, selected_ids) + |> assign(:file_type_filter, filter) + |> assign(:search_query, "") + |> assign(:current_page, page) + |> assign(:per_page, @per_page) + |> allow_upload(:media_files, + accept: :any, + max_entries: 10, + auto_upload: true, + progress: &handle_progress/3 + ) + + {:ok, socket} + end + + def handle_params(params, _uri, socket) do + page = String.to_integer(params["page"] || "1") + + # Load files based on current filters and page + {files, total_count} = load_files(socket, page) + total_pages = ceil(total_count / socket.assigns.per_page) + + socket = + socket + |> assign(:current_page, page) + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + + {:noreply, socket} + end + + def handle_event("toggle_selection", %{"file-id" => file_id}, socket) do + selected_ids = socket.assigns.selected_ids + mode = socket.assigns.selection_mode + + new_selected_ids = + case mode do + :single -> + # Single mode: replace selection + MapSet.new([file_id]) + + :multiple -> + # Multiple mode: toggle selection + if MapSet.member?(selected_ids, file_id) do + MapSet.delete(selected_ids, file_id) + else + MapSet.put(selected_ids, file_id) + end + end + + {:noreply, assign(socket, :selected_ids, new_selected_ids)} + end + + def handle_event("confirm_selection", _params, socket) do + return_to = socket.assigns.return_to + selected_ids = socket.assigns.selected_ids + + # Build return URL with selected_media param + selected_media_param = selected_ids |> MapSet.to_list() |> Enum.join(",") + + return_url = + if String.contains?(return_to, "?") do + "#{return_to}&selected_media=#{selected_media_param}" + else + "#{return_to}?selected_media=#{selected_media_param}" + end + + {:noreply, push_navigate(socket, to: return_url)} + end + + def handle_event("cancel_selection", _params, socket) do + {:noreply, push_navigate(socket, to: socket.assigns.return_to)} + end + + def handle_event("search", %{"search" => %{"query" => query}}, socket) do + socket = + socket + |> assign(:search_query, query) + |> assign(:current_page, 1) + + # Reload files with search query + {files, total_count} = load_files(socket, 1) + total_pages = ceil(total_count / socket.assigns.per_page) + + socket = + socket + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + + {:noreply, socket} + end + + def handle_event("filter_type", %{"filter" => filter}, socket) do + parsed_filter = parse_filter(filter) + + socket = + socket + |> assign(:file_type_filter, parsed_filter) + |> assign(:current_page, 1) + + # Reload files with new filter + {files, total_count} = load_files(socket, 1) + total_pages = ceil(total_count / socket.assigns.per_page) + + socket = + socket + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + + {:noreply, socket} + end + + def handle_event("validate", _params, socket) do + {:noreply, socket} + end + + def handle_event("save", _params, socket) do + # Files are auto-uploaded, just reload the list + {files, total_count} = load_files(socket, 1) + total_pages = ceil(total_count / socket.assigns.per_page) + + socket = + socket + |> assign(:current_page, 1) + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + |> put_flash(:info, "Files uploaded successfully") + + {:noreply, socket} + end + + defp handle_progress(:media_files, entry, socket) do + if entry.done? do + # Process the completed upload + consume_uploaded_entry(socket, entry, fn %{path: path} -> + process_upload(socket, path, entry) + end) + end + + {:noreply, socket} + end + + defp process_upload(socket, path, entry) do + # Get file info + ext = Path.extname(entry.client_name) |> String.replace_leading(".", "") + mime_type = entry.client_type || MIME.from_path(entry.client_name) + file_type = determine_file_type(mime_type) + + # Get current user + current_user = socket.assigns[:phoenix_kit_current_user] + user_id = if current_user, do: current_user.id, else: 1 + + # Calculate hash + file_hash = Auth.calculate_file_hash(path) + + # Store file in storage + case Storage.store_file_in_buckets( + path, + file_type, + user_id, + file_hash, + ext, + entry.client_name + ) do + {:ok, file, :duplicate} -> + Logger.info("Duplicate file uploaded: #{file.id}") + {:ok, file.id} + + {:ok, file} -> + Logger.info("New file uploaded: #{file.id}") + {:ok, file.id} + + {:error, reason} -> + Logger.error("Upload failed: #{inspect(reason)}") + {:error, reason} + end + end + + defp load_files(socket, page) do + repo = Application.get_env(:phoenix_kit, :repo) + per_page = socket.assigns.per_page + filter = socket.assigns.file_type_filter + search = socket.assigns.search_query + + # Build query + query = from(f in File, order_by: [desc: f.inserted_at]) + + # Apply file type filter + query = + case filter do + :image -> where(query, [f], f.file_type == "image") + :video -> where(query, [f], f.file_type == "video") + :all -> query + end + + # Apply search filter + query = + if search != "" do + search_pattern = "%#{search}%" + where(query, [f], ilike(f.original_file_name, ^search_pattern)) + else + query + end + + # Get total count + total_count = repo.aggregate(query, :count, :id) + + # Get paginated files + offset = (page - 1) * per_page + + files = + query + |> limit(^per_page) + |> offset(^offset) + |> repo.all() + + # Batch load instances to avoid N+1 + file_ids = Enum.map(files, & &1.id) + + instances_by_file = + if Enum.any?(file_ids) do + from(fi in FileInstance, + where: fi.file_id in ^file_ids + ) + |> repo.all() + |> Enum.group_by(& &1.file_id) + else + %{} + end + + # Convert to file data maps + files_with_urls = + Enum.map(files, fn file -> + instances = Map.get(instances_by_file, file.id, []) + urls = generate_urls_from_instances(instances, file.id) + + %{ + file_id: file.id, + filename: file.original_file_name || file.file_name || "Unknown", + original_filename: file.original_file_name, + file_type: file.file_type, + mime_type: file.mime_type, + size: file.size || 0, + status: file.status, + urls: urls, + width: get_width_from_instances(instances), + height: get_height_from_instances(instances) + } + end) + + {files_with_urls, total_count} + end + + defp generate_urls_from_instances(instances, file_id) do + Enum.reduce(instances, %{}, fn instance, acc -> + url = URLSigner.signed_url(file_id, instance.variant_name) + Map.put(acc, instance.variant_name, url) + end) + end + + defp get_width_from_instances(instances) do + case Enum.find(instances, &(&1.variant_name == "original")) do + nil -> nil + instance -> instance.width + end + end + + defp get_height_from_instances(instances) do + case Enum.find(instances, &(&1.variant_name == "original")) do + nil -> nil + instance -> instance.height + end + end + + defp determine_file_type(mime_type) do + cond do + String.starts_with?(mime_type, "image/") -> "image" + String.starts_with?(mime_type, "video/") -> "video" + true -> "other" + end + end + + defp parse_mode(nil), do: :single + defp parse_mode("single"), do: :single + defp parse_mode("multiple"), do: :multiple + defp parse_mode(_), do: :single + + defp parse_selected_ids(nil), do: MapSet.new() + + defp parse_selected_ids(selected_string) when is_binary(selected_string) do + selected_string + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> MapSet.new() + end + + defp parse_selected_ids(_), do: MapSet.new() + + defp parse_filter(nil), do: :all + defp parse_filter("image"), do: :image + defp parse_filter("video"), do: :video + defp parse_filter("all"), do: :all + defp parse_filter(_), do: :all + + defp format_file_size(bytes) when is_number(bytes) do + cond do + bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 2)} GB" + bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 2)} MB" + bytes >= 1024 -> "#{Float.round(bytes / 1024, 2)} KB" + true -> "#{bytes} B" + end + end + + defp format_file_size(_), do: "0 B" + + defp pagination_range(current_page, total_pages) do + cond do + total_pages <= 7 -> + Enum.to_list(1..total_pages) + + current_page <= 4 -> + [1, 2, 3, 4, 5, :ellipsis, total_pages] + + current_page >= total_pages - 3 -> + [1, :ellipsis | Enum.to_list((total_pages - 4)..total_pages)] + + true -> + [1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages] + end + end +end diff --git a/lib/phoenix_kit_web/live/users/media_selector.html.heex b/lib/phoenix_kit_web/live/users/media_selector.html.heex new file mode 100644 index 000000000..f79c83baa --- /dev/null +++ b/lib/phoenix_kit_web/live/users/media_selector.html.heex @@ -0,0 +1,247 @@ + +
+ <%!-- Header with Actions --%> +
+
+
+

+ Select Media +

+

+ <%= if @selection_mode == :single do %> + Click on an image to select it + <% else %> + Select one or more images + <% end %> + <%= if MapSet.size(@selected_ids) > 0 do %> + + {MapSet.size(@selected_ids)} selected + + <% end %> +

+
+
+ + +
+
+
+ + <%!-- Upload Section --%> +
+
+

+ <.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload New Files +

+ <.form for={%{}} phx-change="validate" phx-submit="save"> + <.live_file_input upload={@uploads.media_files} class="hidden" id="file-upload-input" /> + + + <%!-- Upload Progress --%> + <%= for entry <- @uploads.media_files.entries do %> +
+
+ {entry.client_name} + {entry.progress}% +
+ + +
+ <% end %> + +
+
+ + <%!-- Search and Filter Section --%> +
+
+
+ <%!-- Search --%> +
+ <.form for={%{}} phx-submit="search" class="w-full"> +
+ + +
+ +
+ + <%!-- Filter by Type --%> +
+ +
+
+
+
+ + <%!-- Media Grid --%> +
+
+ <%= if Enum.empty?(@uploaded_files) do %> +
+ <.icon name="hero-photo" class="w-16 h-16 mx-auto mb-4 text-base-content/30" /> +

No media files found

+

+ Upload files using the area above or adjust your search filters +

+
+ <% else %> +
+ <%= for file <- @uploaded_files do %> + <% is_selected = MapSet.member?(@selected_ids, file.file_id) %> +
+ <%!-- Thumbnail --%> +
+ <%= if file.file_type == "image" do %> + {file.filename} + <% else %> +
+ <%= if file.file_type == "video" do %> + <.icon name="hero-video-camera" class="w-16 h-16 text-base-content/30" /> + <% else %> + <.icon name="hero-document" class="w-16 h-16 text-base-content/30" /> + <% end %> +
+ <% end %> + + <%!-- Selection Indicator --%> + <%= if is_selected do %> +
+ <.icon name="hero-check" class="w-5 h-5" /> +
+ <% else %> +
+
+ <% end %> + + <%!-- File Type Badge --%> +
+ + {file.file_type} + +
+
+ + <%!-- File Info --%> +
+

+ {file.filename} +

+
+ {format_file_size(file.size)} + <%= if file.width && file.height do %> + {file.width}×{file.height} + <% end %> +
+
+
+ <% end %> +
+ <% end %> +
+
+ + <%!-- Pagination --%> + <%= if @total_pages > 1 do %> +
+
+ <%= if @current_page > 1 do %> + <.link + navigate={ + "#{@current_path}?page=#{@current_page - 1}&return_to=#{URI.encode_www_form(@return_to)}&mode=#{@selection_mode}&filter=#{@file_type_filter}" + } + class="join-item btn" + > + « + + <% else %> + + <% end %> + + <%= for page <- pagination_range(@current_page, @total_pages) do %> + <%= if page == :ellipsis do %> + + <% else %> + <.link + navigate={ + "#{@current_path}?page=#{page}&return_to=#{URI.encode_www_form(@return_to)}&mode=#{@selection_mode}&filter=#{@file_type_filter}" + } + class={"join-item btn #{if page == @current_page, do: "btn-active"}"} + > + {page} + + <% end %> + <% end %> + + <%= if @current_page < @total_pages do %> + <.link + navigate={ + "#{@current_path}?page=#{@current_page + 1}&return_to=#{URI.encode_www_form(@return_to)}&mode=#{@selection_mode}&filter=#{@file_type_filter}" + } + class="join-item btn" + > + » + + <% else %> + + <% end %> +
+
+ <% end %> +
+