Packages

phoenix_kit

2.2.0
2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 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 live users media_selector.ex
Raw

lib/phoenix_kit_web/live/users/media_selector.ex

defmodule PhoenixKitWeb.Live.Users.MediaSelector do
@moduledoc """
Generic media selector LiveView.
Provides a reusable interface for selecting media files from anywhere in the admin panel.
Supports both single and multiple selection modes.
## Usage
# Navigate to selector with query params
/admin/media/selector?return_to=/admin/publishing/edit&mode=single&filter=image
## Query Parameters
- `return_to` - URL to navigate back to (required)
- `mode` - "single" or "multiple" (default: "single")
- `selected` - Comma-separated pre-selected file IDs (optional)
- `filter` - "image", "video", "all" (default: "all")
- `page` - Page number for pagination (default: "1")
"""
use PhoenixKitWeb, :live_view
# Search, file-type filter and page live in the query string, so a filtered
# list is a real URL: shareable, reload-proof, and Back returns to the
# previous query instead of leaving the page. `file_type_filter` defaults to
# :all, which is therefore what gets omitted from the URL.
use PhoenixKitWeb.Live.UrlState,
params: [
search_query: [default: "", url_key: "q"],
file_type_filter: [
default: :all,
cast: :atom,
in: [:all, :image, :video],
url_key: "type",
alias: "filter"
],
current_page: [default: 1, cast: :integer, min: 1, url_key: "page"]
],
page_param: :current_page
require Logger
alias PhoenixKit.Modules.Storage
alias PhoenixKit.Modules.Storage.{File, FileInstance, URLSigner}
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
alias PhoenixKit.Utils.Format
alias PhoenixKit.Utils.Routes
import Ecto.Query
@per_page 30
def mount(params, _session, socket) do
# Handle locale
locale =
params["locale"] || socket.assigns[:current_locale]
# Get project title
project_title = Settings.get_project_title()
# Parse mount-only query parameters (not URL state — these are fixed for
# the lifetime of the session and are not list filters).
return_to = parse_return_to(params["return_to"])
mode = parse_mode(params["mode"])
selected_uuids = parse_selected_uuids(params["selected"])
# :search_query, :file_type_filter, and :current_page are assigned from
# the query string by UrlState before mount/3 runs — re-assigning them
# here would overwrite a shared link's state with the defaults.
socket =
socket
|> assign(:current_locale, locale)
|> assign(:current_path, Routes.path("/admin/media/selector"))
|> assign(:project_title, project_title)
|> assign(:page_title, gettext("Select Media"))
|> assign(:return_to, return_to)
|> assign(:selection_mode, mode)
|> assign(:selected_uuids, selected_uuids)
|> assign(:per_page, @per_page)
|> allow_upload(:media_files,
accept: :any,
max_entries: 10,
auto_upload: true,
progress: &handle_progress/3
)
{:ok, socket}
end
# The list is loaded here rather than in mount/3: UrlState calls this after
# mount and on every change to the query string, so one code path serves the
# first render, a shared link, and the Back button alike.
#
# Deliberately not annotated with @impl — a single @impl anywhere in a module
# makes Elixir demand it on every other callback too, and this LiveView's
# mount/handle_event/handle_params/handle_info carry none.
def handle_url_state(_state, socket), do: reload_files(socket)
def handle_event("toggle_selection", %{"file-uuid" => file_uuid}, socket) do
selected_uuids = socket.assigns.selected_uuids
mode = socket.assigns.selection_mode
new_selected_uuids =
case mode do
:single ->
# Single mode: replace selection
[file_uuid]
:multiple ->
# Multiple mode: toggle selection. Selection is an ordered list —
# append keeps the user's pick order, which consumers receive
# through `selected_media`.
if file_uuid in selected_uuids do
List.delete(selected_uuids, file_uuid)
else
selected_uuids ++ [file_uuid]
end
end
{:noreply, assign(socket, :selected_uuids, new_selected_uuids)}
end
def handle_event("confirm_selection", _params, socket) do
return_to = socket.assigns.return_to
selected_uuids = socket.assigns.selected_uuids
# Build return URL with selected_media param
selected_media_param = Enum.join(selected_uuids, ",")
return_url =
if String.contains?(return_to, "?") do
"#{return_to}&selected_media=#{selected_media_param}"
else
"#{return_to}?selected_media=#{selected_media_param}"
end
{:noreply, push_navigate(socket, to: return_url)}
end
def handle_event("cancel_selection", _params, socket) do
{:noreply, push_navigate(socket, to: socket.assigns.return_to)}
end
# `replace: true` — the box is debounced, so a typed-out query would otherwise
# leave one history entry per pause and Back would walk the search string
# backwards instead of leaving the page.
def handle_event("search", %{"search" => %{"query" => query}}, socket) do
{:noreply, push_url_state(socket, [search_query: query], replace: true)}
end
def handle_event("filter_type", %{"filter" => filter}, socket) do
{:noreply, push_url_state(socket, file_type_filter: parse_filter(filter))}
end
def handle_event("validate", _params, socket) do
{:noreply, socket}
end
def handle_event("save", _params, socket) do
# Files are auto-uploaded via handle_progress; this only refreshes the grid.
# The reload cannot be left to the patch: on page 1 the URL does not change,
# so handle_url_state would never fire and the new file would not appear.
socket = put_flash(socket, :info, "Files uploaded successfully")
{:noreply, reset_to_first_page(socket)}
end
# Data changed underneath the current query: reload in place when already on
# page 1, otherwise patch back to it and let handle_url_state do the loading.
defp reset_to_first_page(socket) do
if socket.assigns.current_page == 1 do
reload_files(socket)
else
push_url_state(socket, current_page: 1)
end
end
defp reload_files(socket) do
{files, total_count} = load_files(socket, socket.assigns.current_page)
socket
|> assign(:uploaded_files, files)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / socket.assigns.per_page))
end
defp handle_progress(:media_files, entry, socket) do
if entry.done? do
result =
consume_uploaded_entry(socket, entry, fn %{path: path} ->
process_upload(socket, path, entry)
end)
socket =
case result do
file_uuid when is_binary(file_uuid) ->
# Refresh the grid so the new file is visible, and auto-select it —
# uploading into a picker means "I want this one".
selected =
case socket.assigns.selection_mode do
:single -> [file_uuid]
:multiple -> Enum.uniq(socket.assigns.selected_uuids ++ [file_uuid])
end
socket
|> assign(:selected_uuids, selected)
|> reset_to_first_page()
_ ->
put_flash(
socket,
:error,
gettext("Upload failed. Check that at least one storage bucket is configured.")
)
end
{:noreply, socket}
else
{:noreply, socket}
end
end
defp process_upload(socket, path, entry) do
# Get file info
ext = Path.extname(entry.client_name) |> String.replace_leading(".", "")
mime_type = entry.client_type || MIME.from_path(entry.client_name)
# Shared classifier — see `Storage.determine_file_type/2`. The copy that
# lived here classified every audio upload as "other".
file_type = Storage.determine_file_type(mime_type, entry.client_name)
# Get current user
current_user = socket.assigns[:phoenix_kit_current_user]
user_uuid = if current_user, do: current_user.uuid, else: nil
# Calculate hash
file_hash = Auth.calculate_file_hash(path)
# Store file in storage
case Storage.store_file_in_buckets(
path,
file_type,
user_uuid,
file_hash,
ext,
entry.client_name
) do
{:ok, file, :duplicate} ->
Logger.info("Duplicate file uploaded: #{file.uuid}")
{:ok, file.uuid}
{:ok, file} ->
Logger.info("New file uploaded: #{file.uuid}")
{:ok, file.uuid}
{:error, reason} ->
# consume_uploaded_entry only accepts {:ok, _} | {:postpone, _} —
# returning {:error, _} here raised and crashed the LiveView.
Logger.error("Upload failed: #{inspect(reason)}")
{:postpone, :error}
end
end
defp load_files(socket, page) do
repo = PhoenixKit.Config.get_repo()
per_page = socket.assigns.per_page
filter = socket.assigns.file_type_filter
search = socket.assigns.search_query
# Build query. Same exclusions as MediaSelectorModal: a picker must not
# offer trashed or system-managed files.
query =
from(f in File, order_by: [desc: f.inserted_at])
|> where([f], f.status != "trashed" and f.system_managed == false)
# Apply file type filter
query =
case filter do
:image -> where(query, [f], f.file_type == "image")
:video -> where(query, [f], f.file_type == "video")
:all -> query
end
# Apply search filter
query =
if search != "" do
search_pattern = "%#{search}%"
where(query, [f], ilike(f.original_file_name, ^search_pattern))
else
query
end
# Get total count
total_count = repo.aggregate(query, :count, :uuid)
# Get paginated files
offset = (page - 1) * per_page
files =
query
|> limit(^per_page)
|> offset(^offset)
|> repo.all()
# Batch load instances to avoid N+1
file_uuids = Enum.map(files, & &1.uuid)
instances_by_file =
if Enum.any?(file_uuids) do
from(fi in FileInstance,
where: fi.file_uuid in ^file_uuids
)
|> repo.all()
|> Enum.group_by(& &1.file_uuid)
else
%{}
end
# Convert to file data maps
files_with_urls =
Enum.map(files, fn file ->
instances = Map.get(instances_by_file, file.uuid, [])
urls = generate_urls_from_instances(instances, file.uuid)
%{
file_uuid: file.uuid,
filename: file.original_file_name || file.file_name || "Unknown",
original_filename: file.original_file_name,
file_type: file.file_type,
mime_type: file.mime_type,
size: file.size || 0,
status: file.status,
urls: urls,
# Saved orientation — thumbnails apply it as a CSS transform, so
# the picker shows images the same way up as the media grid.
rotation: Map.get(file.metadata || %{}, "rotation"),
width: get_width_from_instances(instances),
height: get_height_from_instances(instances)
}
end)
{files_with_urls, total_count}
end
defp generate_urls_from_instances(instances, file_uuid) do
Enum.reduce(instances, %{}, fn instance, acc ->
url = URLSigner.signed_url(file_uuid, instance.variant_name)
Map.put(acc, instance.variant_name, url)
end)
end
defp get_width_from_instances(instances) do
case Enum.find(instances, &(&1.variant_name == "original")) do
nil -> nil
instance -> instance.width
end
end
defp get_height_from_instances(instances) do
case Enum.find(instances, &(&1.variant_name == "original")) do
nil -> nil
instance -> instance.height
end
end
defp parse_mode(nil), do: :single
defp parse_mode("single"), do: :single
defp parse_mode("multiple"), do: :multiple
defp parse_mode(_), do: :single
# `return_to` is user-controlled (query param) and fed to `push_navigate` —
# accept only local paths so a crafted link can't bounce through the selector
# to an external site (or crash navigation with a full URL).
defp parse_return_to(<<"/", next, _::binary>> = path) when next not in [?/, ?\\], do: path
defp parse_return_to("/"), do: "/"
defp parse_return_to(_), do: "/"
defp parse_selected_uuids(nil), do: []
defp parse_selected_uuids(selected_string) when is_binary(selected_string) do
selected_string
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
end
defp parse_selected_uuids(_), do: []
defp parse_filter(nil), do: :all
defp parse_filter("image"), do: :image
defp parse_filter("video"), do: :video
defp parse_filter("all"), do: :all
defp parse_filter(_), do: :all
defp format_file_size(bytes), do: Format.bytes(bytes, decimals: 2, unknown: "0 B")
# Folds the old in-body subtitle text + selection-count badge into a single
# string for `page_subtitle` (string-only attr, can't carry a colored pill).
defp selection_subtitle(:single, _count), do: gettext("Click on an image to select it")
defp selection_subtitle(:multiple, 0), do: gettext("Select one or more images")
defp selection_subtitle(:multiple, count) do
gettext("Select one or more images") <>
" — " <> ngettext("%{count} selected", "%{count} selected", count, count: count)
end
defp pagination_range(current_page, total_pages) do
cond do
total_pages <= 7 ->
Enum.to_list(1..total_pages)
current_page <= 4 ->
[1, 2, 3, 4, 5, :ellipsis, total_pages]
current_page >= total_pages - 3 ->
[1, :ellipsis | Enum.to_list((total_pages - 4)..total_pages)]
true ->
[1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages]
end
end
end