# `PhoenixKitWeb.Components.MediaBrowser`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v3.5.0/lib/phoenix_kit_web/components/media_browser.ex#L1)

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: uuid | nil,
  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`).

# `handle_event`

# `handle_parent_info`

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

# `render`

# `setup_uploads`

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`

```elixir
@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`

---

*Consult [api-reference.md](api-reference.md) for complete listing*
