defmodule PhoenixKitWeb.Components.MediaBrowser do
# PhoenixKitComments is an optional sibling package — silence undefined
# warnings for parent apps that don't install it. The comments_enabled?/0
# helper guards every actual call at runtime with Code.ensure_loaded?/1.
@compile {:no_warn_undefined, [PhoenixKitComments, PhoenixKitComments.Web.CommentsComponent]}
@moduledoc """
MediaBrowser LiveComponent — embeddable media management UI.
Provides a full media browser with folder navigation, file upload, search,
and selection tools. Operates in two modes:
- **Uncontrolled** (default): all navigation state is owned internally.
No URL sync.
- **Controlled** (opt-in): when `on_navigate` is set to a truthy value,
navigation events (`navigate_folder`, `search`, `clear_search`, `set_page`)
emit `{PhoenixKitWeb.Components.MediaBrowser, id, {:navigate, params}}`
to the parent LiveView process instead of mutating local state. The parent
must call `push_patch` and feed URL params back via `send_update` with a
`:nav_params` key.
## Enabling uploads
The fastest path is the `Embed` helper — one `use` line gives you
uploads, the validate-channel stub, and the message delegator:
defmodule MyAppWeb.MediaPage do
use MyAppWeb, :live_view
use PhoenixKitWeb.Components.MediaBrowser.Embed
end
Manual setup (if you prefer explicit wiring) is three calls:
def mount(_params, _session, socket) do
{:ok, PhoenixKitWeb.Components.MediaBrowser.setup_uploads(socket)}
end
def handle_event("validate", _params, socket), do: {:noreply, socket}
def handle_info({__MODULE__, _, _} = msg, socket) do
PhoenixKitWeb.Components.MediaBrowser.handle_parent_info(msg, socket)
end
## Usage (uncontrolled)
<.live_component
module={PhoenixKitWeb.Components.MediaBrowser}
id="media-browser"
phoenix_kit_current_user={@phoenix_kit_current_user}
/>
## Usage (controlled — URL-sync driven by parent)
<.live_component
module={PhoenixKitWeb.Components.MediaBrowser}
id="media-browser"
phoenix_kit_current_user={@phoenix_kit_current_user}
on_navigate={true}
/>
The parent LiveView must implement:
def handle_info({PhoenixKitWeb.Components.MediaBrowser, "media-browser",
{:navigate, params}}, socket) do
# push_patch to update URL, handle_params will send_update back
end
## Required attributes
- `id` — unique DOM id (required by LiveComponent)
## Optional attributes
- `phoenix_kit_current_user` — logged-in user struct (for upload attribution)
- `scope_folder_id` — constrain the browser to a virtual root folder
- `on_navigate` — when truthy, enables controlled mode (URL-sync via parent)
- `admin` — when `true`, clicking a file opens the admin detail page at
`/admin/media/:uuid`. When `false` (default), clicks toggle selection
instead, so the component behaves as a picker when embedded outside the
admin UI.
- `viewer` — when `true`, clicks open a read-only modal showing the
clicked file (image / video / PDF / icon) with its metadata and a
Download button. Standard close behaviour (X button, Esc, click on
backdrop). `admin` and `select_mode` both win over `viewer` if also
set, so a caller can opt into modal viewing without losing the
selection picker for users who explicitly enable select mode.
"""
use PhoenixKitWeb, :live_component
require Logger
import Ecto.Query
alias Phoenix.LiveView.JS
alias PhoenixKit.Modules.Storage
alias PhoenixKit.Modules.Storage.FileInstance
alias PhoenixKit.Modules.Storage.URLSigner
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
alias PhoenixKit.Utils.Routes
# ──────────────────────────────────────────────────────────────
# Lifecycle
# ──────────────────────────────────────────────────────────────
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:scope_folder_id, fn -> nil end)
|> assign_new(:admin, fn -> false end)
|> assign_new(:viewer, fn -> false end)
|> assign_new(:viewer_file, fn -> nil end)
cond do
not Map.has_key?(socket.assigns, :uploaded_files) ->
socket = init_socket(socket)
# Register this component id with the parent so it can route uploads here
send(self(), {__MODULE__, :register_component, socket.assigns.id})
# Apply initial params if provided (avoids flash of root before correct view)
socket =
if Map.has_key?(assigns, :initial_params),
do: apply_nav_params(socket, assigns.initial_params),
else: socket
{:ok, socket}
Map.has_key?(assigns, :nav_params) ->
{:ok, apply_nav_params(socket, socket.assigns.nav_params)}
Map.has_key?(assigns, :pending_upload) ->
{:ok, process_pending_upload(socket, assigns.pending_upload)}
Map.get(assigns, :action) == :commit_upload_batch ->
{:ok, commit_upload_batch(socket)}
true ->
{:ok, socket}
end
end
# Processes one entry and buffers the result. A single reload + flash is
# committed from commit_upload_batch/1 once the batch debounce window closes,
# so dropping N files produces one page reload instead of N.
defp process_pending_upload(socket, {path, entry}) do
result = process_single_upload(socket, path, entry)
File.rm(path)
batch = [result | socket.assigns[:pending_batch] || []]
new_uuids =
case result do
{:ok, %{file_uuid: uuid}} -> [uuid | socket.assigns.last_uploaded_file_uuids]
_ -> socket.assigns.last_uploaded_file_uuids
end
socket =
socket
|> assign(:pending_batch, batch)
|> assign(:last_uploaded_file_uuids, new_uuids)
if socket.assigns[:batch_scheduled] do
socket
else
Phoenix.LiveView.send_update_after(
__MODULE__,
[id: socket.assigns.id, action: :commit_upload_batch],
250
)
assign(socket, :batch_scheduled, true)
end
end
defp commit_upload_batch(socket) do
batch = socket.assigns[:pending_batch] || []
{flash_type, flash_msg} = build_upload_flash_message(Enum.reverse(batch))
socket
|> assign(:pending_batch, [])
|> assign(:batch_scheduled, false)
|> reload_current_page()
|> put_flash(flash_type, flash_msg)
end
defp apply_nav_params(socket, params) do
q = params[:q] || ""
page = params[:page] || 1
filter_orphaned = params[:filter_orphaned] || false
file_view = params[:view]
scope = scope_folder_id(socket)
per_page = socket.assigns.per_page
{current_folder, breadcrumbs, folders, scoped_fallback?} =
resolve_folder(params[:folder], scope)
actual_uuid = current_folder && current_folder.uuid
{files, total_count} =
load_nav_files(scope, page, per_page, q, actual_uuid, filter_orphaned, file_view)
orphaned_count =
if filter_orphaned,
do: total_count,
else: Storage.count_orphaned_files(scope)
socket
|> assign(:current_folder, current_folder)
|> assign(:breadcrumbs, breadcrumbs)
|> assign(:folders, if(file_view == "all", do: [], else: folders))
|> assign(:search_query, q)
|> assign(:current_page, page)
|> assign(:filter_orphaned, filter_orphaned)
|> assign(:filter_trash, false)
|> assign(:file_view, file_view)
|> assign(:orphaned_count, orphaned_count)
|> assign(:trash_count, Storage.count_trashed_files(scope_folder_id(socket)))
|> assign(:uploaded_files, files)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / per_page))
|> then(
&if(scoped_fallback?,
do: put_flash(&1, :info, "Folder not accessible — showing root"),
else: &1
)
)
|> auto_expand_breadcrumbs(breadcrumbs)
end
defp resolve_folder(folder_uuid, scope) do
if folder_uuid in [nil, ""] do
{nil, [], Storage.list_folders(nil, scope), false}
else
folder = Storage.get_folder(folder_uuid)
if folder && Storage.within_scope?(folder.uuid, scope) do
bc = Storage.folder_breadcrumbs(folder.uuid, scope)
flds = Storage.list_folders(folder.uuid, scope)
{folder, bc, flds, false}
else
# Folder not found or outside scope — fall back to scope root.
{nil, [], Storage.list_folders(nil, scope), not is_nil(folder_uuid)}
end
end
end
defp load_nav_files(scope, page, per_page, q, actual_uuid, filter_orphaned, file_view) do
cond do
filter_orphaned -> load_orphaned_files(page, per_page)
file_view == "all" -> load_all_view_files(scope, page, per_page, q)
true -> load_scoped_files(scope, page, per_page, actual_uuid, q)
end
end
defp init_socket(socket) do
scope = scope_folder_id(socket)
scope_folder = if scope, do: Storage.get_folder(scope)
scope_invalid = not is_nil(scope) and is_nil(scope_folder)
enabled_buckets = Storage.list_enabled_buckets()
has_buckets = not Enum.empty?(enabled_buckets)
scope_name = if scope_folder, do: scope_folder.name, else: "Root"
socket
|> maybe_allow_upload(has_buckets)
|> assign(:has_buckets, has_buckets)
|> assign(:scope_invalid, scope_invalid)
|> assign(:scope_folder_name, scope_name)
|> assign(:show_upload, false)
|> assign(:show_search, false)
|> assign(:last_uploaded_file_uuids, [])
|> assign(:pending_batch, [])
|> assign(:batch_scheduled, false)
|> assign(:filter_orphaned, false)
|> assign(:filter_trash, false)
|> assign(:trash_count, Storage.count_trashed_files(scope_folder_id(socket)))
|> assign(:file_view, nil)
|> assign(
:orphaned_count,
if(scope_invalid, do: 0, else: Storage.count_orphaned_files(scope))
)
|> assign(:current_folder, nil)
|> assign(:breadcrumbs, [])
|> assign(:folders, if(scope_invalid, do: [], else: Storage.list_folders(nil, scope)))
|> assign(:folder_tree, if(scope_invalid, do: [], else: Storage.list_folder_tree(scope)))
|> assign(:show_new_folder, false)
|> assign(:sidebar_collapsed, false)
|> assign(:expanded_folders, MapSet.new())
|> assign(:renaming_folder, nil)
|> assign(:renaming_source, nil)
|> assign(:renaming_text, "")
|> assign(:view_mode, "grid")
|> assign(:search_query, "")
|> assign(:select_mode, false)
|> assign(:selected_files, MapSet.new())
|> assign(:selected_folders, MapSet.new())
|> assign(:show_move_modal, false)
|> assign(:current_page, 1)
|> assign(:per_page, 50)
|> then(fn s ->
if scope_invalid,
do: assign(s, uploaded_files: [], total_count: 0, total_pages: 0),
else: reload_current_page(s)
end)
end
# ──────────────────────────────────────────────────────────────
# Upload
# ──────────────────────────────────────────────────────────────
@doc """
Handles the upload setup message from the MediaBrowser component.
Parent LiveViews embedding MediaBrowser should add this to their `handle_info`:
def handle_info({PhoenixKitWeb.Components.MediaBrowser, _id, :setup_uploads}, socket) do
{:noreply, PhoenixKitWeb.Components.MediaBrowser.setup_uploads(socket)}
end
Or use the catch-all delegator:
def handle_info({PhoenixKitWeb.Components.MediaBrowser, _, _} = msg, socket) do
PhoenixKitWeb.Components.MediaBrowser.handle_parent_info(msg, socket)
end
"""
def setup_uploads(socket) do
if socket.assigns[:uploads] do
socket
else
max_size_mb =
Settings.get_setting_cached("storage_max_upload_size_mb", "500")
|> String.to_integer()
socket
|> assign(:max_upload_size_mb, max_size_mb)
|> allow_upload(:media_files,
accept: :any,
max_entries: 10,
max_file_size: max_size_mb * 1_000_000,
auto_upload: true,
progress: &__MODULE__.parent_progress/3
)
end
end
@doc false
# Progress callback that runs on the parent LiveView socket.
# Consumes the upload and sends the file to the MediaBrowser component for processing.
def parent_progress(:media_files, entry, socket) do
if entry.done? do
# Persist file to a temp path since consume_uploaded_entry cleans up the original
persistent_path = Path.join(System.tmp_dir!(), "pk_upload_#{entry.uuid}")
result =
consume_uploaded_entry(socket, entry, fn %{path: path} ->
File.cp!(path, persistent_path)
{:ok, :done}
end)
if result == :done do
# Payload is wrapped in a tuple so the outer message stays a 3-tuple;
# the Embed macro's handle_info matches `{Mod, _, _}` only.
send(self(), {__MODULE__, :process_pending_upload, {persistent_path, entry}})
end
end
{:noreply, socket}
end
@doc "Catch-all handler for parent LiveViews to delegate MediaBrowser messages."
def handle_parent_info({__MODULE__, :register_component, id}, socket) do
ids = MapSet.put(socket.assigns[:media_browser_ids] || MapSet.new(), id)
{:noreply, assign(socket, :media_browser_ids, ids)}
end
def handle_parent_info({__MODULE__, :process_pending_upload, {path, entry}}, socket) do
# Forward the upload to every registered MediaBrowser. If more than one is on
# the page, copy the temp file per recipient so each component owns its own
# path — otherwise the first one to run would delete the shared file and the
# rest would crash on `File.stat/1`.
component_ids =
socket.assigns[:media_browser_ids]
|> Kernel.||(MapSet.new())
|> MapSet.to_list()
case component_ids do
[] ->
File.rm(path)
[single] ->
send_update(__MODULE__, id: single, pending_upload: {path, entry})
[first | rest] ->
send_update(__MODULE__, id: first, pending_upload: {path, entry})
Enum.each(rest, fn id ->
copy = "#{path}-#{id}"
case File.cp(path, copy) do
:ok -> send_update(__MODULE__, id: id, pending_upload: {copy, entry})
_ -> :ok
end
end)
end
{:noreply, socket}
end
def handle_parent_info(_msg, socket), do: {:noreply, socket}
defp maybe_allow_upload(socket, has_buckets) do
if has_buckets do
max_size_mb =
Settings.get_setting_cached("storage_max_upload_size_mb", "500")
|> String.to_integer()
assign(socket, :max_upload_size_mb, max_size_mb)
else
assign(socket, :max_upload_size_mb, 0)
end
end
@doc false
def handle_progress(:media_files, entry, socket) do
socket =
if entry.done? do
result =
consume_uploaded_entry(socket, entry, fn %{path: path} ->
process_single_upload(socket, path, entry)
end)
{flash_type, flash_msg} = build_upload_flash_message([result])
new_uuids =
case result do
{:ok, %{file_uuid: uuid}} -> [uuid | socket.assigns.last_uploaded_file_uuids]
_ -> socket.assigns.last_uploaded_file_uuids
end
socket
|> reload_current_page()
|> put_flash(flash_type, flash_msg)
|> assign(:last_uploaded_file_uuids, new_uuids)
else
socket
end
{:noreply, socket}
end
# ──────────────────────────────────────────────────────────────
# Function components
# ──────────────────────────────────────────────────────────────
attr :node, :map, required: true
attr :current_folder, :any, required: true
attr :expanded_folders, :any, required: true
attr :renaming_folder, :any, required: true
attr :renaming_text, :string, default: ""
attr :show_new_folder, :boolean, default: false
attr :renaming_source, :any, required: true
attr :depth, :integer, default: 0
attr :myself, :any, required: true
def folder_tree_node(assigns) do
assigns =
assign(
assigns,
:is_active,
assigns.current_folder && assigns.current_folder.uuid == assigns.node.folder.uuid
)
assigns =
assign(
assigns,
:is_expanded,
MapSet.member?(assigns.expanded_folders, assigns.node.folder.uuid)
)
assigns = assign(assigns, :has_children, assigns.node.children != [])
assigns =
assign(
assigns,
:is_renaming,
assigns.renaming_folder == assigns.node.folder.uuid &&
assigns.renaming_source == "sidebar"
)
~H"""
<%!-- Chevron (expand/collapse) --%>
<%= if @has_children do %>
<% else %>
<% end %>
<%= if @is_renaming do %>
<%!-- Inline rename form --%>
<% else %>
<%!-- Folder button (uncontrolled: phx-click instead of .link navigate) --%>
<%!-- Rename button (visible on hover) --%>
<% end %>
<%!-- Children (expanded or active with new folder form) --%>
<%= if (@has_children && @is_expanded) || (@is_active && @show_new_folder) do %>
<%= for child <- @node.children do %>
<.folder_tree_node
node={child}
current_folder={@current_folder}
expanded_folders={@expanded_folders}
renaming_folder={@renaming_folder}
renaming_source={@renaming_source}
renaming_text={@renaming_text}
show_new_folder={@show_new_folder}
depth={@depth + 1}
myself={@myself}
/>
<% end %>
<%= if @is_active && @show_new_folder do %>