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 --%>
+
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.")}
-
-
+
+
- {gettext("Open Media Manager")}
-
-
+ <.icon name="hero-photo" class="w-4 h-4 mr-1" />
+ {gettext("Select from Media Library")}
+
+ <%= if @form["featured_image_id"] && @form["featured_image_id"] != "" do %>
+
+ <.icon name="hero-x-mark" class="w-4 h-4 mr-1" />
+ {gettext("Clear")}
+
+ <% 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 --%>
+
+
+ <%!-- 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" />
+
+
+ <.icon name="hero-cloud-arrow-up" class="w-10 h-10 mb-3 text-base-content/50" />
+
+ Click to upload or drag and drop
+
+
Images, videos, or documents
+
+
+
+ <%!-- 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">
+
+
+
+ <.icon name="hero-magnifying-glass" class="w-4 h-4" />
+
+
+
+
+
+ <%!-- Filter by Type --%>
+
+
+ All Files
+ Images Only
+ Videos Only
+
+
+
+
+
+
+ <%!-- 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 %>
+
+ <% 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 %>
+
+