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_navigateis 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 callpush_patchand feed URL params back viasend_updatewith a:nav_paramskey.
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
endManual 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)
endUsage (uncontrolled)
<.live_component
module={PhoenixKitWeb.Components.MediaBrowser}
id="media-browser"
phoenix_kit_current_user={@phoenix_kit_current_user}
/>Picking into a typed slot
only_file_type restricts the browser to one kind for its whole lifetime —
the listing is filtered to it, the type-filter control is hidden, and an
off-type upload is refused, so the other kinds are simply not reachable:
<.live_component
module={PhoenixKitWeb.Components.MediaBrowser}
id="audio-picker"
select_mode
only_file_type="audio"
phoenix_kit_current_user={@phoenix_kit_current_user}
/>Use it wherever the selection fills a typed field. Offering everything and
validating afterwards works, but it lets someone choose a PNG for an audio
slot and only find out on the rejection — and if a consumer forgets that
re-check, the PNG lands in the field and renders as a dead <audio>.
Values: image, video, document, audio, archive, other.
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
endparams carries :file — the uuid of the file open in the modal viewer, or
nil when it is closed. Put it in the URL and hand it back in :nav_params
and a refresh, or the browser's Back button, reopens or closes the viewer.
A host that leaves the key out of :nav_params altogether keeps the viewer
local to the component; only an explicit file: nil closes it.
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 folderon_navigate— when truthy, enables controlled mode (URL-sync via parent)admin— clicking a file always opens the in-place modal viewer (image / video / PDF / icon + metadata + Download + prev/next navigation). Whentrue, the viewer sidebar additionally shows an "Open details page" button linking to/admin/media/:uuid, and rotations made in the viewer persist to the file row. Bulk-select is still reachable via the toolbar's Select button — onceselect_modeis on, clicks toggle selection instead of opening the modal.readonly(defaultfalse) — embed the browser for viewing only. Every write-capable affordance is hidden — the upload zone and drag-drop upload input, the toolbar's Add Media / Select / New folder controls (including the stacks-view "+" tile and the sidebar's own "+"/rename/drag affordances), every folder and file kebab menu item except Download, and everydata-draggable-*/data-drop-*drag attribute. Navigation, search, sorting, the modal viewer, and downloads keep working. Whenfeaturedis also set, the featured tile's star still renders (display only) but the other tiles' star toggles and the kebab's Set/Unset featured item do not.The hidden markup is a courtesy, not the boundary: every mutating
handle_eventclause (upload, rename, move, trash, new folder, bulk-select entry, rotate, featured toggle, open the image editor) refuses outright whenreadonlyis set, because the event is still reachable from a console — same reasoning as theonly_file_typelock onset_file_filter. The same applies one level down, inside the modal viewer'sMediaCanvasViewer: readonly passescan_annotate={false}(Etcher shapes lock),edit_target={nil}ANDdetails_path={nil}(title/alt/description save refuses only when BOTH are nil), andpersist_rotation={false}.Typical use: embedding the browser on a page where the viewer should only look, not touch —
<.live_component module={PhoenixKitWeb.Components.MediaBrowser} id="order-files-viewonly" scope_folder_id={@order.storage_folder_uuid} readonly />featured—nil(default, feature off) or `%{uuid: uuidnil, label: String.t() nil}` naming the host's own featured-image pointer.
labelis the star badge's tooltip; falls back to gettext "Featured image" when nil/absent. When set, image tiles/rows gain a "Set as featured" / "Unset featured" kebab item (grid, list and stack views) and a star toggle in the top-left corner of every image tile (grid and stack views): a solid star on the matching tile (data-role="featured-badge", a click clears it), an outline star on the others (data-role="featured-toggle", a click moves the pointer there). In select mode, the trash, orreadonly, only the matching tile shows its star, as a plain badge. The modal viewer's sidebar (for image files) shows the same toggle. The browser never persists anything itself: choosing or clearing a featured image sends{__MODULE__, id, {:set_featured, uuid | nil}}to the host process — the{MediaBrowser, id, payload}channel{:navigate, _}already uses — and optimistically moves the badge locally. ⚠️ A host that funnels every{MediaBrowser, _, _}message intohandle_parent_info/2must match{:set_featured, _}before it: that function handles only its own two internal messages and ends in a catch-all, so the choice is swallowed silently — no crash, no log, the star flips in the UI and nothing is ever persisted. The host persists the choice and, if the write is rejected or the pointer changes elsewhere, corrects it with a laterfeaturedassign (e.g. viasend_update).
Summary
Functions
Callback implementation for Phoenix.LiveComponent.handle_event/3.
Catch-all handler for parent LiveViews to delegate MediaBrowser messages.
Callback implementation for Phoenix.LiveComponent.render/1.
Handles the upload setup message from the MediaBrowser component.
Callback implementation for Phoenix.LiveComponent.update/2.
Functions
Callback implementation for Phoenix.LiveComponent.handle_event/3.
Catch-all handler for parent LiveViews to delegate MediaBrowser messages.
Callback implementation for Phoenix.LiveComponent.render/1.
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)}
endOr use the catch-all delegator:
def handle_info({PhoenixKitWeb.Components.MediaBrowser, _, _} = msg, socket) do
PhoenixKitWeb.Components.MediaBrowser.handle_parent_info(msg, socket)
end
Callback implementation for Phoenix.LiveComponent.update/2.