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"
picker
pick_mode={:single}
only_file_type="audio"
parent_uploads={@uploads}
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.
Picker
picker turns the browser into a window for choosing files — the folder
tree, upload into the open folder, search and sort stay; what is chosen goes
back to the host:
<.live_component
module={PhoenixKitWeb.Components.MediaBrowser}
id="slot-picker"
picker
pick_mode={:multiple}
max_select={4}
selected={@slot_file_uuids}
scope_folder_id={@order_folder_uuid}
folder_labels={%{@kitchen_folder_uuid => "Kitchen"}}
folder_actions={false}
only_file_type="image"
parent_uploads={@uploads}
phoenix_kit_current_user={@phoenix_kit_current_user}
/>picker(defaultfalse) — a click on a file picks it and opens no viewer; folders are browsed, never picked. The view is always the grid (no view switch), and the picker writes none of the user's view or folder-tree preferences. Bulk select (Select, box select, Shift-click, long press) is off: the pick is its own ordered list. The toolbar shows the count with Confirm and Cancel. Set it when the browser is mounted: the view, the tree and the upload registration are made then, so turning it on or off on a mounted browser leaves it half one and half the other. Mount another browser instead.pick_mode—:multiple(default) keeps the files in the order they were picked, a second click takes one out;:singlereplaces the pick on every click, and a double click picks and confirms.max_select— the most files:multipletakes (nil, the default, is no limit). A click past it is refused and the toolbar says so.selected— the pick the browser opens with, in order. Read on the first mount only: a later render of the host (an upload tick, any changed assign) leaves the working pick alone. Replace it withsend_update(MediaBrowser, id: id, picker_reset: uuids). Both keep what a click could pick (below), at mostmax_selectof it (one in:single); anything else is dropped without a word, and Confirm returns the list without it — a slot's file outside the scope, in the trash or of another type leaves the slot when the person confirms.notify—{module, id}of a host LiveComponent. Confirm sends itsend_update(module, id: id, picked: uuids), Cancelpicked: :cancelled, and a change of the upload statepicker_uploading: boolean. Withoutnotifythe host LiveView process gets THREE messages, and must match all three (or have a catch-allhandle_info):{MediaBrowser, id, {:picked, uuids}},{MediaBrowser, id, :pick_cancelled}and{MediaBrowser, id, {:picker_uploading, boolean}}— the last one with every upload, whether or not the host cares. ⚠️ A host that funnels every{MediaBrowser, _, _}message intohandle_parent_info/2must match them before it, as with{:set_featured, _}: the delegate only logs them. A Confirm or Cancel pressed again with nothing changed is reported once, so a host that keeps the picker mounted between openings (hidden, not removed) sendspicker_resetwhen it opens it again — otherwise a Confirm of the same pick reports nothing.folder_labels—%{folder_uuid => label}: the label is shown instead of the folder's name in the tree, the breadcrumbs, the title and the folder tiles. Display only; nothing is renamed.folder_actions(defaulttrue) —falsetakes away New folder, renaming and every other folder edit (header, description, colour, cover), moving files and folders, trashing and deleting folders, the sidebar's Trash link and drag moves: hidden AND refused on the server. Dropping files from the computer still uploads. A file moved to the Trash from such a browser cannot be brought back from it (the Trash view and Restore are folder actions here). Outside a picker the file cards stay draggable for the selection gestures (long press, Shift-click, box select), but no folder, tree node or Trash takes a move, and folders are never selected. The content area stays a drop target, for files dropped from the computer; a move dropped there is refused on the server.allow_move(defaultfalse; a picker only — outside one it changes nothing, moves there followfolder_actionsalone) — the picker's files can be dragged to a folder of the tree (the top level included) or a folder tile, and are moved there, whateverfolder_actionssays. A card that is picked drags the whole pick (the files already in the target stay, and the pick is unchanged, in the same order); a card that is not drags alone. The server moves files inside the scope only, each one checked as a click on it is (live, this library, the viewer's, ofonly_file_type, inside the scope), and a batch is the browser's own pick, never a list from the client (the drag sendsmove_picked_to_folder; the move dialog'smove_selected_to_folderis not touched). The drop must be on a folder you point at — a folder of the tree, the top level or a folder tile — never on the grid itself, and not on the folder a card is already in; a pick with files in other folders (picked while walking the tree) is gathered into the folder shown by a drop on its node in the tree. Folders are not dragged, the Trash takes no drop, and every other folder action stays refused underfolder_actions: false.readonlystill takes it all away. The move is a mouse drag: a touch screen has no drag, so a picker on one has no move.subtree_toggle(defaultfalse; a picker only) — puts an "Include subfolders" switch in the picker's bar. Off, the picker lists the folder it stands in, as it always did. On, it lists the files of that folder AND of every folder below it (homed or linked, each file once), flat, with no folder tiles, paged like any listing; at the top of the scope that is all of the scope. The switch is the picker's own state: it holds while the person walks the tree (another folder shows its own subtree), is not part of the URL, and starts off on every mount. Search, the type, shape and sort options, the library and the viewer's visibility all apply as they do to a folder; the scope is never widened. The switch is not offered over the Trash, the orphaned files or All Files, and those listings are their own whatever the switch says. A file listed from a subfolder is picked, viewed, trashed and (withallow_move) dragged as a file of that subfolder. Works in a controlled browser (on_navigate) too — the switch is applied here, and a page or folder change that comes back through the nav params keeps it.per_page(a picker only; default 50, at most 500) — the files on a page. Anything that is not a positive integer is 50. A change on a mounted picker reloads it from the first page. Not read outside a picker.
A picker's file menu keeps Download and Move to Trash. Rotate, Edit image, the featured toggle and deleting for good (the Trash view's Delete, Empty Trash) are hidden and refused: the files are someone's, opened to be chosen from.
Every pick is checked on the server, whatever the click named: a file of
this library the viewer may see, live (not in the trash), inside the scope
folder (homed there or linked into it) and of only_file_type. Confirm
leaves out a picked file that has been trashed or deleted since. A file
uploaded in the picker picks itself, under the same check — the store
answers with the row it kept, and a duplicate of an earlier upload is that
row, in the trash or of another type: :single replaces the pick,
:multiple adds it while there is room under max_select.
While an upload is on its way (a transfer, or a file waiting to be stored)
Confirm and Cancel are disabled with a note, and refused, and the host is
told (picker_uploading) so it can keep its own close control from
dropping the upload.
A file's tile has an eye: it opens the viewer on that file and changes
nothing in the pick (nor does closing the viewer). Previous / next step
through the files loaded on the page of the open folder. The viewer's
Select / Deselect picks or unpicks the file shown, under the same rules
and server check as a click (:single replaces; Deselect empties it);
Select is disabled while :multiple is at max_select. The viewer writes
nothing to the file — no rotation saved, no annotations, no title or
description, no image editor, no featured toggle, refused on the server as
under readonly — but offers Move to trash for a live file (not under
readonly; folder_actions: false does not take it away, it is an action
on a file). A file removed that way leaves the pick.
Escape closes the viewer and nothing else: the pick stays and the picker
stays open. The viewer opens in the browser's top layer (a popover), so
a host modal whose box is transformed (daisyUI's .modal-box is) cannot
clip it, and while it is open its Escape is cancelled for the page, so a
host <dialog> — core <.modal>, or any dialog with the PkDialog hook —
stays open: hosts of those need nothing. A host that closes its modal on
Escape by its own key listener (phx-window-keydown, a script) must skip
an Escape whose defaultPrevented is set, or it closes with the viewer.
A picker is usually a second browser on its page, so its DOM ids carry its
id (<id>-media-browser-sidebar, <id>-folder-drop-upload-form, …).
Without picker the ids are the ones they have always been — hosts, tests
and scripts select them by name — so two browsers that are not pickers on
one page still share them, as before.
A picker uploads through an upload of its own, :media_picker_files, which
setup_uploads/1 (and so Embed) registers next to :media_files with the
same limits. A page has one file input per upload: sharing :media_files
would give a picker opened beside another browser a second input with the
same id, the other browser's transfers in its list, and its uploads copied
into the other browser's folder too. A picker's upload goes to the pickers
only, any other upload to the other browsers only. A host that calls
allow_upload itself instead of setup_uploads/1 adds
:media_picker_files with the same options (progress: &MediaBrowser.parent_progress/3) for a picker beside another browser;
without it the picker uses :media_files, which is fine while it is the
only browser on the page. only_file_type is held for either upload.
Removal guard
A file can be in use where the media library cannot see — an order's
attachment, a page's picture. config :phoenix_kit, :file_removal_guard, MyApp.FileRemovalGuard names a module (the
PhoenixKit.Modules.Storage.RemovalGuard behaviour) that every media
browser asks before a file disappears, the Media page of the core included:
removal_guard— overrides the configured module for one browser;falsegoes without. With no module configured, nothing here applies: the browser acts and confirms as it always did.phoenix_kit_current_scope— the scope of the person acting, handed to the guard asctx.scope. A host that narrows rights to the person's active role passes the narrowed scope; without it the scope is built fromphoenix_kit_current_user.
The events that can remove a file — trash_file (tile, menu, viewer, the drag
onto Trash), delete_file, delete_selected (folders in it too),
trash_folder, delete_folder and empty_trash — are stopped before their
handlers. Storage.removal_plan/2, the reading the operations themselves
decide by, says which files would be trashed or deleted; a file only unlinked
from a folder or re-homed to another that holds it is not asked about. The
module is asked where the others are in use, and a server dialog (inside
the browser, above an open viewer, Escape closes it and not the viewer or the
picker) asks the person:
- no place: "Move 3 files to trash?" / "Delete 3 files for good?" with the names, then the action runs;
- places: "Cannot delete: the file is in use", with the places (links open in
a new tab; a place with no label is "not available to you"). When the module
has
release/2andrelease_label/1gives this person a text, a button with it frees the files, the module is asked again, and the confirmation follows; - Empty trash and deleting a selection of files in the Trash view skip the files that are in use (they stay in the trash): "2 deleted, 1 kept: in use".
The browser's own data-confirm for these actions is dropped while a guard is
configured (it would be a second question). A folder is confirmed in the
dialog even when no file goes with it. The confirmation runs the same event
again through every gate (readonly, viewer_uuid, own_files_only, the shown
library) and from a fresh plan: if the files, or what is in use, changed since
the dialog was drawn, the dialog is drawn again instead of removing. A guard
that raises, exits or answers a shape that is not the contract removes
nothing (the log names the module and the exception, no request data).
Not covered, by design: the daily Storage.prune_trash/1, the host's own
background jobs, the API and the console, and "Delete all orphaned" (an orphan
is in no folder).
Taking a browser off the page
The page routes every finished upload to each browser that registered on
mount. A host that stops rendering one (a closed modal, another tab) calls
unregister_browser/1 with its id, or uploads keep being routed — and
copied — to it. With no browser registered an upload is not deleted: it
stays in the user's upload inbox and shows under "Upload problems", with
Retry, in the next browser opened.
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; required for access checks when showing a user library)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.upload_resume(default:auto) — what to do with files whose transfer was cut short (the page was refreshed, the tab discarded, the connection lost) and whose copy the browser kept::autosends them again by itself,:asklists them as upload problems with a Resume button. Files the server has already taken, or is receiving, are never sent again; a file too large for the browser to keep is listed either way, to be added again. If a re-send cannot start in the browser, the file is listed with Resume.:autoapplies only to a resumable upload (the default, see Resumable uploads in Settings -> Media): the server's record of what it has taken comes from there, and without it the browser asks. That record is kept for the signed-in person (phoenix_kit_current_user), the ownerResumableUpload.external/1uses by default; a host whose ownallow_uploadnames another:ownershould use:ask.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}.A browser showing a managed library (
library_uuid, seePhoenixKit.Modules.Storage.Libraries.managed?/1) isreadonlywhatever the host passed.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.
Takes the browser id off the page's upload routing.
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
@spec unregister_browser(String.t()) :: :ok
Takes the browser id off the page's upload routing.
Every browser registers with its LiveView when it mounts, and the page sends
each finished upload — and a copy of it — to every browser registered. A
host that stops rendering a browser (a closed modal, a switched tab) calls
this, from the LiveView or from one of its LiveComponents (both run in the
LiveView's process), so nothing is routed to a browser that is gone. It
sends {MediaBrowser, :unregister, id} to the calling process, which
handle_parent_info/2 handles. Rendering the browser again registers it
again — a new mount; a browser kept mounted (hidden) and unregistered is
not registered again, and its uploads go to the inbox. Unregister only a
browser you stop rendering.
Callback implementation for Phoenix.LiveComponent.update/2.