Packages

phoenix_kit

1.7.180
1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web components media_gallery.ex
Raw

lib/phoenix_kit_web/components/media_gallery.ex

defmodule PhoenixKitWeb.Components.MediaGallery do
@moduledoc """
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)}
end
## Attrs
- `id` — required; used for element IDs and change notifications
- `title` — optional heading above the gallery
- `selected` — ordered list of file UUIDs (current selection); default `[]`
- `mode``:single` or `:multiple` (default `:multiple`)
- `cols` — grid columns for the thumbnail layout (default `4`). 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` — when `true`, the first item in `:selected` renders a
"Featured" badge in the top-left corner. Matches the
`phoenix_kit_posts` post-creation convention where the first image is the
featured one and drag-reordering changes the feature. Default `false` so
existing consumers aren't surprised.
- `scope_folder_id` — folder scope passed to the picker
- `phoenix_kit_current_user` — required for upload in the picker
- `readonly` — when `true`, hides the pick button, remove buttons, and DnD;
preview (lightbox) still works; default `false`
- `max_count` — integer upper bound on the number of selected images for
`:multiple` mode; `nil` means unlimited. For `:single` mode the limit is
always 1 (implied by `mode`). When the limit is reached, the "Add" tile is
hidden entirely (not just disabled) and `apply_selection` refuses to exceed it.
## 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.
## 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.
"""
use PhoenixKitWeb, :live_component
require Logger
alias PhoenixKit.Modules.Storage
alias PhoenixKitWeb.Live.Components.MediaSelectorModal
import PhoenixKit.Modules.Shared.Components.ImageSet
@impl true
def update(%{media_viewer_closed: true}, socket) do
{:ok, assign(socket, :preview_uuid, nil)}
end
def update(%{media_selector_closed: true}, socket) do
{:ok, assign(socket, :show_picker, false)}
end
def update(%{media_selected: uuids} = _assigns, socket) do
uuids = uuids || []
# Guard: if component hasn't been fully initialised yet these are nil-safe defaults.
current = socket.assigns[:selected] || []
mode = socket.assigns[:mode] || :multiple
max_count = socket.assigns[:max_count]
new_selected = apply_selection(current, uuids, mode, max_count)
socket =
socket
|> assign_new(:show_picker, fn -> false end)
|> assign(:selected, new_selected)
|> load_files()
|> assign(:show_picker, false)
notify_parent(socket)
{:ok, socket}
end
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:selected, fn -> [] end)
|> assign_new(:mode, fn -> :multiple end)
|> assign_new(:cols, fn -> 4 end)
|> assign_new(:featured_first, fn -> false end)
|> assign_new(:scope_folder_id, fn -> nil end)
|> assign_new(:phoenix_kit_current_user, fn -> nil end)
|> assign_new(:readonly, fn -> false end)
|> assign_new(:max_count, fn -> nil end)
|> assign_new(:title, fn -> nil end)
|> assign_new(:show_picker, fn -> false end)
|> assign_new(:preview_uuid, fn -> nil end)
|> assign_new(:files, fn -> [] end)
|> assign_new(:variants_map, fn -> %{} end)
|> load_files()
{:ok, socket}
end
@impl true
def handle_event("open_picker", _params, socket) do
{:noreply, assign(socket, :show_picker, true)}
end
def handle_event("remove_image", %{"uuid" => uuid}, socket) do
new_selected = Enum.reject(socket.assigns.selected, &(&1 == uuid))
socket = socket |> assign(:selected, new_selected) |> load_files()
notify_parent(socket)
{:noreply, socket}
end
def handle_event("preview_image", %{"uuid" => uuid}, socket) do
{:noreply, assign(socket, :preview_uuid, uuid)}
end
# Reorder event from <.draggable_list>: payload uses `ordered_ids` and the
# event is routed to this component by the SortableGrid hook via
# `pushEventTo` (the grid is rendered with `target={"##{@id}"}`).
def handle_event("reorder_images", %{"ordered_ids" => ids}, socket) do
current = socket.assigns.selected
new_selected = Enum.filter(ids, &(&1 in current))
# Append any current uuids that weren't in the payload (defensive; the
# hook should always include every visible item).
leftovers = Enum.reject(current, &(&1 in ids))
new_selected = new_selected ++ leftovers
socket = socket |> assign(:selected, new_selected) |> load_files()
notify_parent(socket)
{:noreply, socket}
end
# ── Private helpers ────────────────────────────────────────────────────
defp load_files(socket) do
selected = socket.assigns.selected
# Skip the DB round-trip when the selection list hasn't changed.
if selected == socket.assigns[:selected_loaded] do
socket
else
do_load_files(socket, selected)
end
end
defp do_load_files(socket, []) do
assign(socket, files: [], variants_map: %{}, selected_loaded: [])
end
defp do_load_files(socket, selected) do
files = Storage.get_files(selected)
variants_map = Storage.list_image_set_variants_for_files(selected)
assign(socket, files: files, variants_map: variants_map, selected_loaded: selected)
rescue
# Defensive degradation at the UI boundary — if Storage can't be reached
# (DB outage, missing connection, sandbox unavailable in tests, cast on a
# malformed UUID), render an empty gallery instead of crashing the
# LiveView. The cases we explicitly anticipate: DBConnection.ConnectionError
# (network/pool), Ecto.Query.CastError (bad UUID in the list), and
# DBConnection.OwnershipError (Ecto.Adapters.SQL.Sandbox in tests that
# exercise update/handle_event paths without checking out a connection).
e in [
DBConnection.ConnectionError,
DBConnection.OwnershipError,
Ecto.Query.CastError
] ->
Logger.warning("MediaGallery: could not load files — #{Exception.message(e)}")
assign(socket, files: [], variants_map: %{}, selected_loaded: nil)
end
defp apply_selection(_current, uuids, :single, _max_count) do
case uuids do
[uuid | _] -> [uuid]
[] -> []
end
end
defp apply_selection(_current, uuids, _multiple, nil), do: uuids
defp apply_selection(_current, uuids, _multiple, max_count)
when is_integer(max_count) and max_count > 0 do
Enum.take(uuids, max_count)
end
defp apply_selection(_current, uuids, _multiple, _max_count), do: uuids
# Returns true when the current selection has reached its limit:
# - :single mode → limit is always 1
# - :multiple with a positive max_count → limit is max_count
# - :multiple with nil or 0 max_count → unlimited (always false)
defp selection_at_limit?(selected, :single, _max_count), do: selected != []
defp selection_at_limit?(selected, _multiple, max_count)
when is_integer(max_count) and max_count > 0 do
length(selected) >= max_count
end
defp selection_at_limit?(_selected, _mode, _max_count), do: false
defp notify_parent(socket) do
parent = self()
id = socket.assigns.id
selected = socket.assigns.selected
send(parent, {PhoenixKitWeb.Components.MediaGallery, id, {:changed, selected}})
end
end