One entity's "main image" control: a thumbnail, an always-visible actions menu (Change / Remove, plus View when a preview is wired up) and the whole media-picker protocol behind it.
The component owns the picker — it mounts MediaSelectorModal itself, resets
its own open flag on both of the modal's replies, refuses anything that is
not a live image — and leaves the host exactly one required message. It
writes nothing: where the pointer lives (a data key, a metadata field, a
setting, a column) and when it is saved (at once, or with the surrounding
form) are the host's business.
Usage
<.live_component
module={PhoenixKitWeb.Components.FeaturedImage}
id="order-featured"
uuid={@order.featured_image_uuid}
picker_scope={@order.storage_folder_uuid && {:folder, @order.storage_folder_uuid}}
phoenix_kit_current_user={@current_user}
readonly={@order.deleted?}
on_preview={{"show_order_card", %{uuid: @order.uuid}}}
/>
def handle_info({PhoenixKitWeb.Components.FeaturedImage, "order-featured", {:set_featured, uuid}}, socket) do
# `uuid` is a file uuid, or `nil` when the user removed the image.
# Authorize, persist, then assign the new value back — the component
# draws whatever `uuid` it is handed and nothing else.
{:noreply, socket}
endThe payload has the same shape as MediaBrowser's :featured relay, so one
host function can write both.
Attrs
There are no required attrs beyond id.
id— required. Every DOM id anddata-roleis derived from it, and it travels in every message the component sends.uuid— the current pointer, ornil. The component is controlled: it draws exactly what the host passes and never updates it optimistically. Anything that is not a live image (a uuid with no row, a trashed file, a non-image, a failed upload, a string that is not a UUID at all) is drawn as a "missing" placeholder whose menu still offers Remove, so a dangling pointer can always be cleared. Areadonlyviewer sees nothing for it (or the:emptyslot), never a warning they cannot act on.""counts asnil.picker_scope— where the picker looks and where uploads land:{:folder, folder_uuid}— the entity's folder. The picker is narrowed to it (and its subfolders) and files uploaded from it are filed there.:lazy— the entity has no folder yet; the host creates one on demand (see "Lazy scope" below).nil(default) — choosing is unavailable. The modal is never mounted: with no scope it would browse the whole library.
Not named
scope: that is@phoenix_kit_current_scopeeverywhere else in PhoenixKit. There is deliberately no whole-library value — an upload made outside any folder is an orphan candidate for storage's cleanup.phoenix_kit_current_user— required by the picker for uploads.readonly—false.truedraws the thumbnail only: no menu, no picker, no empty placeholder. Also enforced on every event and message the component handles, since an event can be sent around the UI.size—:md.:sm48px,:md64px,:lg96px (the:lgthumbnail uses thesmallvariant, the othersthumbnail).shape—:square(rounded-lg, default) or:circle(avatars).label— the picker's title and the control's accessible name; defaults to a translated "Featured image".on_preview— what a click on the thumbnail does (and adds a "View" item to the menu). One of"event",{"event", %{key: value}}or a%Phoenix.LiveView.JS{}. There is nophx-target, so the event goes to the host LiveView. The component only knows the file's uuid, whereas a host usually needs its entity's, so the map is sent asphx-value-*params:{"show_card", %{uuid: @order.uuid}}arrives as%{"uuid" => order_uuid}. Values must be strings or numbers. Leftnil, a click on the thumbnail opens the picker instead (when choosing is possible).confirm_remove— adata-confirmtext for Remove, for hosts that write the change at once and want to ask first.report_close—false.truesends:picker_closed(below).
Slots
:empty— replaces the empty placeholder, e.g. an avatar's initials. It keeps the control's size and shape, still opens the picker when choosing is possible, and is drawn for areadonlyviewer as well (they should still see the initials). It also stands in for a dangling pointer: the frame is marked "Image missing" (title and screen-reader text) and the menu still offers Remove, but the host's placeholder is what is seen.:empty_hint— drawn instead of the placeholder whenpicker_scopeisnil, e.g. an "Upload files first" button that switches to the Files tab. Not drawn forreadonly(read-only wins).
Required host wiring — silent failure otherwise
This is a LiveComponent, so it has no handle_info of its own: it reports
to the host LiveView process with send/2, and the host MUST handle
{PhoenixKitWeb.Components.FeaturedImage, id, {:set_featured, uuid | nil}}Forget it and the user's choice is silently dropped (no crash, no warning).
A host that funnels every message into a catch-all handle_info must match
this one before it.
The other two messages are opt-in, so a host that does not use them needs no clause for them:
{FeaturedImage, id, :scope_requested}— only withpicker_scope: :lazy.{FeaturedImage, id, :picker_closed}— only withreport_close: true. Sent whenever the picker goes away, however it ended (chosen, cancelled, rejected), always after:set_featured— except for areadonlycomponent, which sends nothing at all. A host with a file list of its own refreshes it here: uploads made inside the picker land in the folder even when nothing was chosen.
Also needed by the app, not the component: the RowMenu hook (from
PhoenixKitHooks) registered in the host's app.js, or the actions menu is
dead markup; and Phoenix LiveView 1.1 or newer — 1.2.12 or newer is
recommended, portals were still being fixed until then (see "The modal
lives in a portal").
Reacting to a file trashed elsewhere
The Storage lookup behind display is memoized per uuid
(load_display/1) — once settled (:empty, :ok, :dangling) it is not
looked up again on its own, so a file trashed, restored, or permanently
deleted in another tab or browser (see
Storage.subscribe_to_file_events/0) does not change what is drawn here
until the host pokes it:
send_update(FeaturedImage, id: id, refresh: true)A host wires this itself — the component has no handle_info of its own —
by subscribing on mount and matching the event's uuid against whatever it
currently has pointed at this instance:
def mount(socket) do
if connected?(socket), do: Storage.subscribe_to_file_events()
{:ok, socket}
end
@one ~w(phoenix_kit_file_trashed phoenix_kit_file_restored phoenix_kit_file_deleted)a
@many ~w(phoenix_kit_files_trashed phoenix_kit_files_restored phoenix_kit_files_deleted)a
# One file trashed/restored/deleted directly...
def handle_info({event, uuid}, socket) when event in @one,
do: refresh_if_mine(socket, [uuid])
# ...or swept up with its folder, announced once for the whole folder.
def handle_info({event, uuids}, socket) when event in @many,
do: refresh_if_mine(socket, uuids)
defp refresh_if_mine(socket, uuids) do
if socket.assigns.order.featured_image_uuid in uuids,
do: send_update(FeaturedImage, id: "order-featured", refresh: true)
{:noreply, socket}
endUnlike recheck: true (used internally while a file is still
:processing), refresh: true re-resolves unconditionally — the one to
reach for whenever the file's status may have changed regardless of what is
currently displayed. See MediaBrowser.attach_file_event_forwarding/1 for
the same subscribe/dispatch shape wired through an attach_hook instead of
a plain handle_info clause.
Caveat for a host that also runs MediaBrowser.setup_uploads/1: that hook
:halts the trashed / restored / deleted events after forwarding them to
its browsers, so the handle_info clause above never sees them. Attach
your own :handle_info hook before setup_uploads/1 (hooks run in
attach order) and return {:cont, socket} from it.
Lazy scope
Most hosts create the entity's folder on the first click, not on page load.
With picker_scope: :lazy a click on Choose/Change opens nothing; it sends the
host {FeaturedImage, id, :scope_requested}, and the host answers:
def handle_info({FeaturedImage, id, :scope_requested}, socket) do
case ensure_folder(socket) do
{:ok, socket} ->
# `socket` now carries the folder, so the next render passes
# `picker_scope={{:folder, uuid}}`; then ask the component to open.
send_update(FeaturedImage, id: id, open_picker: true)
{:noreply, socket}
:refused ->
# The host explains itself (its own flash); nothing opens.
send_update(FeaturedImage, id: id, open_picker: false)
{:noreply, put_flash(socket, :error, "Restore this contact first")}
{:error, message} ->
# ...or let the component show the message under the control.
send_update(FeaturedImage, id: id, error: message)
{:noreply, socket}
end
endThe reply does not carry the folder: the component takes its scope from the
picker_scope attr only, so the host must have updated its assign before
replying. If open_picker: true arrives while the scope is still not a
folder, nothing opens (and a warning is logged). While an answer is pending,
further clicks do nothing. A :lazy host that never answers leaves the
control inert — every branch above must reply.
Nested LiveViews (not verified)
The actions menu is a RowMenu whose <ul> is moved to <body> while open,
and the picker is a <.portal>. Both work in a host's main LiveView; neither
has been exercised inside a live_render-ed child LiveView (an embedded
project view, say), where a portaled phx-target={@myself} click might not
find its component. From LiveView's sources this looks fine, but no test can
run the client — check it in a browser before relying on it, and fall back to
table_row_menu mode="inline" if it misbehaves.
The modal lives in a portal
The picker is rendered through <.portal target="body">. MediaSelectorModal
has a <form> and a file input of its own, while most hosts place this
control inside their own <.form>; nested forms are invalid HTML and the
upload would post to the wrong form. The portal lifts the modal out of the
form (and out of any card or overflow clipping). In tests, content inside a
portal is invisible to has_element?/2 and render_click/2: assert on the
portal element instead (render(element(view, "#<id>-portal"))) and drive the
modal's reply with send_update(FeaturedImage, id: id, media_selected: [uuid]).
All the component's own buttons are type="button", so it is safe inside a
host form.
The component does not authorize
It draws Change/Remove for anyone it is not readonly for, and it relays what
the user picked. Whether this user may change this entity's image, whether the
chosen file belongs in this entity's folder, and whether the write goes
through are all the host's to decide, in the :set_featured handler. The
component checks only that a chosen file exists, is an image, is not in the
trash and is not a system-managed row (an edited image's hidden original). The same goes for a staged form: a dangling pointer is only
drawn as missing; it stays in the host's state and will be saved as it was
unless the host validates on write.
Not for lists
Each instance makes one Storage query when its uuid changes (memoized per
uuid). For a table of rows draw the thumbnails from data the host already has
instead.
Actions and their data-roles
Everything is addressed as "<name>-<id>": featured-image, row, thumb,
thumb-img, open-picker, empty, empty-hint, dangling, processing,
menu, change, view, remove, error. The root also carries
data-state (empty, ok, processing or dangling). The portal is
#<id>-portal and the modal inside it #<id>-selector.
Summary
Functions
Callback implementation for Phoenix.LiveComponent.render/1.
Functions
Callback implementation for Phoenix.LiveComponent.render/1.