PhoenixKitWeb.Components.MediaBrowser (phoenix_kit v3.5.0)

Copy Markdown View Source

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}
/>

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 (default false) — 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; :single replaces the pick on every click, and a double click picks and confirms.
  • max_select — the most files :multiple takes (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 with send_update(MediaBrowser, id: id, picker_reset: uuids). Both keep what a click could pick (below), at most max_select of 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 it send_update(module, id: id, picked: uuids), Cancel picked: :cancelled, and a change of the upload state picker_uploading: boolean. Without notify the host LiveView process gets THREE messages, and must match all three (or have a catch-all handle_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 into handle_parent_info/2 must 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) sends picker_reset when 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 (default true) — false takes 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 (default false; a picker only — outside one it changes nothing, moves there follow folder_actions alone) — 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, whatever folder_actions says. 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, of only_file_type, inside the scope), and a batch is the browser's own pick, never a list from the client (the drag sends move_picked_to_folder; the move dialog's move_selected_to_folder is 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 under folder_actions: false. readonly still 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 (default false; 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 (with allow_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; false goes 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 as ctx.scope. A host that narrows rights to the person's active role passes the narrowed scope; without it the scope is built from phoenix_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/2 and release_label/1 gives 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
end

params 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 folder

  • on_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). When true, 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 — once select_mode is 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: :auto sends them again by itself, :ask lists 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. :auto applies 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 owner ResumableUpload.external/1 uses by default; a host whose own allow_upload names another :owner should use :ask.

  • readonly (default false) — 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 every data-draggable-* / data-drop-* drag attribute. Navigation, search, sorting, the modal viewer, and downloads keep working. When featured is 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_event clause (upload, rename, move, trash, new folder, bulk-select entry, rotate, featured toggle, open the image editor) refuses outright when readonly is set, because the event is still reachable from a console — same reasoning as the only_file_type lock on set_file_filter. The same applies one level down, inside the modal viewer's MediaCanvasViewer: readonly passes can_annotate={false} (Etcher shapes lock), edit_target={nil} AND details_path={nil} (title/alt/description save refuses only when BOTH are nil), and persist_rotation={false}.

    A browser showing a managed library (library_uuid, see PhoenixKit.Modules.Storage.Libraries.managed?/1) is readonly whatever 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. label is 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, or readonly, 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 into handle_parent_info/2 must 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 later featured assign (e.g. via send_update).

Summary

Functions

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.

Functions

handle_event(event, params, socket)

Callback implementation for Phoenix.LiveComponent.handle_event/3.

handle_parent_info(arg1, socket)

Catch-all handler for parent LiveViews to delegate MediaBrowser messages.

render(assigns)

Callback implementation for Phoenix.LiveComponent.render/1.

setup_uploads(socket)

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

unregister_browser(id)

@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.

update(assigns, socket)

Callback implementation for Phoenix.LiveComponent.update/2.