defmodule PhoenixKitWeb.Components.FeaturedImage do @moduledoc """ 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} end The 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 and `data-role` is derived from it, and it travels in every message the component sends. * `uuid` — the current pointer, or `nil`. 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. A `readonly` viewer sees nothing for it (or the `:empty` slot), never a warning they cannot act on. `""` counts as `nil`. * `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_scope` everywhere 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`. `true` draws 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`. `:sm` 48px, `:md` 64px, `:lg` 96px (the `:lg` thumbnail uses the `small` variant, the others `thumbnail`). * `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 no `phx-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 as `phx-value-*` params: `{"show_card", %{uuid: @order.uuid}}` arrives as `%{"uuid" => order_uuid}`. Values must be strings or numbers. Left `nil`, a click on the thumbnail opens the picker instead (when choosing is possible). * `confirm_remove` — a `data-confirm` text for Remove, for hosts that write the change at once and want to ask first. * `report_close` — `false`. `true` sends `: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 a `readonly` viewer 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 when `picker_scope` is `nil`, e.g. an "Upload files first" button that switches to the Files tab. Not drawn for `readonly` (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 with `picker_scope: :lazy`. * `{FeaturedImage, id, :picker_closed}` — only with `report_close: true`. Sent whenever the picker goes away, however it ended (chosen, cancelled, rejected), always *after* `:set_featured` — except for a `readonly` component, 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} end Unlike `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 `:halt`s 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 end The 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 `