A general-purpose LiveComponent for selecting, ordering, previewing and removing a set of images.
Generalizes the gallery + picker pattern so any PhoenixKit consumer can embed it instead of re-implementing the plumbing. The component manages an ordered list of file UUIDs and reports changes to the parent LiveView.
Usage
<.live_component
module={PhoenixKitWeb.Components.MediaGallery}
id="my-gallery"
selected={@image_uuids}
phoenix_kit_current_user={@current_user}
/>
# Receive changes in parent LiveView
def handle_info({PhoenixKitWeb.Components.MediaGallery, "my-gallery", {:changed, uuids}}, socket) do
{:noreply, assign(socket, :image_uuids, uuids)}
endAttrs
id— required; used for element IDs and change notificationstitle— optional heading above the galleryselected— ordered list of file UUIDs (current selection); default[]mode—:singleor:multiple(default:multiple)cols— grid columns for the thumbnail layout (default4). Either an integer 1..6, or a string of Tailwind grid-column classes for a responsive grid (e.g."grid-cols-4 lg:grid-cols-6 2xl:grid-cols-8"). Plumbed straight through to<.draggable_list>.featured_first— whentrue, the first item in:selectedrenders a "Featured" badge in the bottom-left corner. Matches thephoenix_kit_postspost-creation convention where the first image is the featured one and drag-reordering changes the feature. Defaultfalseso existing consumers aren't surprised.scope_folder_id— folder scope passed to the pickergroup_by_folder— passed to the picker:truelists a scope that holds subfolders grouped by folder, under one heading each (seeMediaSelectorModal); defaultfalsefolder_labels— passed to the picker:%{folder_uuid => name}shown in its folder headings instead of the stored folder names; default%{}picker_size— the picker'ssize::defaultor:full(the whole viewport, more files a page); default:defaultphoenix_kit_current_user— required for upload in the pickerreadonly— whentrue, hides the pick button, the tile menu, and DnD; preview (lightbox) still works; the server refuses pick, remove, trash and reorder events too; defaultfalseremoval_guard— the module asked before "Move to trash" (see "Tile actions");niltakes the configured one,falsegoes without; defaultnilphoenix_kit_current_scope— the scope of the person acting, handed to the removal guard; optional. A passed scope is used as given when the gallery acts, so the host keeps it current; without it one is built fromphoenix_kit_current_userat the moment of each actionexternal_picker— whentrue, the "Add" tile asks the host to pick instead of opening the built-inMediaSelectorModal(which is then never rendered); see "External picker" below. Defaultfalsemax_count— integer upper bound on the number of selected images for:multiplemode;nilmeans unlimited. For:singlemode the limit is always 1 (implied bymode). When the limit is reached, the "Add" tile is hidden entirely (not just disabled) andapply_selectionrefuses to exceed it.
Tile actions
Each tile has the preview (eye) button in its top-left corner and a ⋮ menu in
its top-right corner. Both are shown on hover on a mouse screen and always on
a touch screen (pointer-coarse), and on keyboard focus. The menu holds:
- Remove from selection — takes the file out of the list (the file itself
stays in the library) and reports
{:changed, uuids}. - Move to trash — sends the file to the media trash, and takes it out of
the list the same way. Shown only to a person who may change that file
(
Libraries.can?(scope, file, :edit): its uploader, an Owner/Admin, amedia.view_allholder), and only for a file that is in the selection.
A readonly gallery shows the eye and no menu, and refuses both events.
How "Move to trash" reuses the media browser's removal guard
The gallery does not copy the browser's removal flow and does not make the
host write one. It keeps its own small version of it, built from the same
parts the browser uses: Storage.removal_plan/2 says whether the file would
disappear, MediaBrowser.Removal asks the host's
PhoenixKit.Modules.Storage.RemovalGuard module (the configured one, or the
removal_guard attribute) where the file is in use, and the browser's
RemovalModal draws the dialog inside the gallery, so it looks and reads
the same (and Escape closes it). The dialog always asks first, as the menu
sits next to the harmless "Remove from selection":
- not in use — "Move 1 file to trash?" with the name; the confirmation trashes the file;
- in use — the places are listed, nothing is removed, and when the guard offers a release (the "unpin everywhere" button) pressing it releases the file and asks again;
- the guard failed — "Could not check the files", nothing is removed.
The confirmation re-reads the file, the plan and the guard's answer and
trashes only what the dialog showed. On success the file is dropped from the
selection and {:changed, uuids} is sent, so the host stores the shorter
list as for any remove. The host needs no extra wiring for the dialog.
The host must persist that {:changed, uuids} (as for every change): a
trashed file is gone from the gallery's list, but the host's stored list
still names it until the host saves the shorter one. A guard that reads only
saved state does not see the gallery's current selection as a use, and an
abandoned form leaves a reference to a trashed file. Store the list when the
message arrives, or make the guard count the unsaved selection as a use.
Change notifications — required host wiring (silent failure otherwise)
This is a LiveComponent, so it has no handle_info of its own: after
any change (pick / remove / reorder) it sends a process message to the
host LiveView via send/2:
{PhoenixKitWeb.Components.MediaGallery, id, {:changed, ordered_uuids}}The host MUST handle this (see the Usage example above) and persist
ordered_uuids — it is a controlled component: it renders whatever the
host passes back as :selected. Forget the handler and the user's
picks/reorders are silently dropped (no crash, no warning). Each host
stores the selection differently (its own field/assoc), so there is
intentionally no use ...Embed macro — the handling is yours to write.
External picker
With external_picker={true} the host supplies its own picker. Clicking
"Add" sends the host LiveView (via send/2, like {:changed, …}):
{PhoenixKitWeb.Components.MediaGallery, id,
{:open_picker, %{selected: uuids, mode: :single | :multiple, max_count: integer | nil}}}The host hands the choice back with send_update/2. media_selected is the
complete new selection, not an addition: it replaces selected, is limited
by mode and max_count the same way as the built-in picker's, and the
gallery then reports it as {:changed, uuids} like any other change. Not
answering (the person cancelled) leaves the gallery as it was. Thumbnails,
reordering, removal and the lightbox work as without the option; uploads
are the host picker's business. A readonly gallery shows no "Add" tile and
refuses open_picker on the server, so the host is never asked.
The host is not asked while the "Add" tile is hidden either — the selection is at
max_count, or a:singlegallery already holds a file — and a forgedopen_pickerthen is refused on the server.Make the host's
handle_infoidempotent: every click sends a request, so a double click delivers two{:open_picker, …}messages.Pass
media_selecteduuids without repeats; the gallery does not deduplicate the list.Return image files only. The built-in picker is locked to images; the gallery does not filter what
media_selectednames, and a PDF or a video renders as a broken thumbnail.Build the reply from the selection the host holds now, not from
request.selected: that is a snapshot taken at the click, and a remove or reorder made while a non-modal host picker was open would be reverted by a reply built from it.<.live_component
module={PhoenixKitWeb.Components.MediaGallery} id="my-gallery" selected={@image_uuids} external_picker={true}/>
def handle_info(
{PhoenixKitWeb.Components.MediaGallery, "my-gallery", {:open_picker, request}}, socket ) do # Open the host's picker; `request.selected` is the selection at the click. {:noreply, assign(socket, :picker_request, request)}end
# Later, when the person confirms in the host's picker: send_update(PhoenixKitWeb.Components.MediaGallery, id: "my-gallery", media_selected: uuids)
Reorder event contract
The thumbnail grid uses the canonical <.draggable_list> primitive
(PhoenixKitWeb.Components.Core.DraggableList), which fires the
"reorder_images" event with payload %{"ordered_ids" => uuids}.
Because MediaGallery is a LiveComponent, the grid is rendered with
target={"##{@id}"} so the SortableGrid hook routes the event via
pushEventTo to this component's own handle_event/3 — not the host
LiveView. Each gallery's id is unique, so multiple galleries on the same
page never cross-deliver reorder events.
Summary
Functions
Callback implementation for Phoenix.LiveComponent.render/1.
Functions
Callback implementation for Phoenix.LiveComponent.render/1.