Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions lib/phoenix_kit_web/components/core/media_thumbnail.ex
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,17 @@ defmodule PhoenixKitWeb.Components.Core.MediaThumbnail do
end
end

# Mirrors MediaCanvasViewer's own normalization: only the four snapped
# angles Fresco emits count; anything else (nil, garbage, a legacy string)
# reads as unrotated.
@doc """
Normalizes a raw saved-rotation value (`metadata["rotation"]`) to one of the
four snapped angles.

Mirrors MediaCanvasViewer's own normalization: only the four snapped
angles Fresco emits count; anything else (nil, garbage, a legacy string)
reads as unrotated (`0`).
"""
@spec normalized_rotation(term()) :: 0 | 90 | 180 | 270
def normalized_rotation(deg), do: normalize_rotation(deg)

defp normalize_rotation(deg) when is_integer(deg), do: Integer.mod(deg, 360)

defp normalize_rotation(deg) when is_binary(deg) do
Expand Down
259 changes: 187 additions & 72 deletions lib/phoenix_kit_web/components/media_browser.ex

Large diffs are not rendered by default.

73 changes: 56 additions & 17 deletions lib/phoenix_kit_web/components/media_browser.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,16 @@
the nav path) can't bleed into the all-files / trash /
orphaned / search views where `header_folder` is nil. --%>
<% header_folder && show_background && @folder_cover_url -> %>
<img
src={@folder_cover_url}
alt=""
class="absolute inset-0 w-full h-full object-cover rounded-xl"
/>
<%!-- The wrapper is the size container so a quarter-turned
cover can swap its dimensions via cq units — see
`cover_image_class/1`. --%>
<div class="absolute inset-0 rounded-xl overflow-hidden [container-type:size]">
<img
src={@folder_cover_url}
alt=""
class={cover_image_class(@folder_cover_rotation)}
/>
</div>
<% hero_hex -> %>
<div
class="absolute inset-0 rounded-xl"
Expand Down Expand Up @@ -427,7 +432,10 @@
<img
src={@folder_logo_url}
alt=""
class="absolute inset-0 w-full h-full object-contain p-1"
class={[
"absolute inset-0 w-full h-full object-contain p-1",
rotation_class(%{rotation: @folder_logo_rotation})
]}
/>
<% else %>
<div class="absolute inset-0 flex items-center justify-center text-base-content/40">
Expand Down Expand Up @@ -473,11 +481,13 @@
class="group relative block w-full aspect-[16/7] rounded-xl overflow-hidden border border-base-300 bg-base-200 cursor-pointer"
>
<%= if @folder_cover_url do %>
<img
src={@folder_cover_url}
alt=""
class="absolute inset-0 w-full h-full object-cover"
/>
<div class="absolute inset-0 [container-type:size]">
<img
src={@folder_cover_url}
alt=""
class={cover_image_class(@folder_cover_rotation)}
/>
</div>
<% else %>
<div class="absolute inset-0 flex flex-col items-center justify-center gap-1 text-base-content/40">
<.icon name="hero-photo" class="w-7 h-7" />
Expand Down Expand Up @@ -623,6 +633,7 @@
alt=""
class={[
"object-contain shrink-0 drop-shadow-lg",
rotation_class(%{rotation: @folder_logo_rotation}),
case header_folder.header_size do
"small" -> "w-12 h-12 sm:w-16 sm:h-16"
"large" -> "w-14 h-14 sm:w-20 sm:h-20"
Expand Down Expand Up @@ -1199,6 +1210,19 @@
<p class="text-base-content/50 text-sm mt-1">
{gettext("Deleted files will appear here")}
</p>
<% @search_query != "" -> %>
<%!-- Zero search results is not "no media" — files exist,
they just don't match. --%>
<.icon
name="hero-magnifying-glass"
class="w-12 h-12 mx-auto text-base-content/30 mb-2"
/>
<p class="text-base-content/70">
{gettext("No files match your search.")}
</p>
<p class="text-base-content/50 text-sm mt-1">
{gettext("Try a different term or clear the search")}
</p>
<% @current_folder -> %>
<.icon
name="hero-cloud-arrow-up"
Expand Down Expand Up @@ -1419,9 +1443,16 @@
phx-target={@myself}
phx-value-id={folder.uuid}
data-confirm={
gettext("Delete '%{name}'? Files move to parent.",
name: folder.name
)
if @filter_trash,
do:
gettext(
"Permanently delete '%{name}' and all its contents? This cannot be undone.",
name: folder.name
),
else:
gettext("Move '%{name}' and all its contents to trash?",
name: folder.name
)
}
icon="hero-trash"
label={gettext("Delete")}
Expand Down Expand Up @@ -2016,9 +2047,17 @@
phx-target={@myself}
phx-value-id={folder.uuid}
data-confirm={
gettext("Delete '%{name}'? Files move to parent.",
name: folder.name
)
if @filter_trash,
do:
gettext(
"Permanently delete '%{name}' and all its contents? This cannot be undone.",
name: folder.name
),
else:
gettext(
"Move '%{name}' and all its contents to trash?",
name: folder.name
)
}
icon="hero-trash"
label={gettext("Delete")}
Expand Down
1 change: 1 addition & 0 deletions lib/phoenix_kit_web/components/media_gallery.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
show={true}
mode={@mode}
selected_uuids={@selected}
max_select={@max_count}
file_type_filter={:image}
scope_folder_id={@scope_folder_id}
phoenix_kit_current_user={@phoenix_kit_current_user}
Expand Down
2 changes: 1 addition & 1 deletion lib/phoenix_kit_web/integration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -435,8 +435,8 @@ defmodule PhoenixKitWeb.Integration do
live "/admin/activity/:uuid", Live.Activity.Show, :show
live "/admin/notifications", Live.Modules.Notifications.Index, :index
live "/admin/media", Live.Users.Media, :index
live "/admin/media/:file_uuid", Live.Users.MediaDetail, :show
live "/admin/media/selector", Live.Users.MediaSelector, :index
live "/admin/media/:file_uuid", Live.Users.MediaDetail, :show
live "/admin/settings", Live.Settings, :index
live "/admin/settings/users", Live.Settings.Users, :index
live "/admin/settings/authorization", Live.Settings.Authorization, :index
Expand Down
140 changes: 111 additions & 29 deletions lib/phoenix_kit_web/live/components/media_selector_modal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do
socket
|> assign(assigns)
|> assign(:has_buckets, has_buckets)
# Normalize once so every handler can rely on :single/:multiple atoms —
# a consumer passing "multiple" as a string used to work for clicking
# but crash the upload auto-select's case.
|> then(&assign(&1, :mode, normalize_mode(&1.assigns[:mode])))
# Optional cap on how many files a :multiple picker accepts (nil =
# unlimited). MediaGallery passes its max_count so users can't select
# past the limit only to have the gallery silently truncate on confirm.
|> assign_new(:max_select, fn -> nil end)
|> assign_new(:limit_hit, fn -> false end)
|> assign_new(:user_uuid, fn -> nil end)
# When set, restricts both the browse query and the post-upload
# home folder to this folder UUID. Plugins scoping the picker to
Expand All @@ -124,21 +133,35 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do
|> assign_new(:total_pages, fn -> 0 end)
|> maybe_allow_upload(has_buckets)

# Handle selected_uuids - only reset when modal is opening, otherwise preserve selection
# Handle selected_uuids - only reset when modal is opening, otherwise
# preserve selection. Selection is an ORDERED list (not a MapSet): the
# order the user picked files in is part of the result — MediaGallery
# treats the first entry as the Featured image, and a set would silently
# re-sort it on every confirm.
socket =
cond do
# Modal is opening (show transitions from false to true) - initialize from incoming assigns
assigns[:show] && !was_shown ->
selected_uuids_list = assigns[:selected_uuids] || []
assign(socket, :selected_uuids, MapSet.new(selected_uuids_list))

socket
|> assign(
:selected_uuids,
normalize_selection(
selected_uuids_list,
socket.assigns.mode,
socket.assigns.max_select
)
)
|> assign(:limit_hit, false)

# Modal already open and has selection state - preserve it
is_struct(previous_selected_uuids, MapSet) ->
is_list(previous_selected_uuids) ->
assign(socket, :selected_uuids, previous_selected_uuids)

# First mount or no previous state - initialize empty
true ->
assign(socket, :selected_uuids, MapSet.new([]))
assign(socket, :selected_uuids, [])
end

# Load files if modal is shown
Expand Down Expand Up @@ -177,6 +200,29 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do
end
end

# The upload `accept` list is fixed at allow_upload time, so the OS file
# dialog wouldn't follow the in-modal type dropdown. Re-allow with the new
# accept whenever the dropdown changes and nothing is in flight
# (disallow_upload raises on active entries); the server-side type gate in
# handle_progress still backstops the in-flight case.
defp refresh_upload_accept(socket, filter) do
uploads = socket.assigns[:uploads]
config = uploads && uploads[:media_files]

if socket.assigns.has_buckets && config && config.entries == [] do
socket
|> disallow_upload(:media_files)
|> allow_upload(:media_files,
accept: accept_for(filter),
max_entries: 10,
auto_upload: true,
progress: &handle_progress/3
)
else
socket
end
end

# Constrain the upload picker to the filtered type. The browse list is already
# scoped by `scope_files_by_type/2`; without this the upload accepted any file
# (`:any`), so an image/video picker would still let arbitrary files in. `:all`
Expand Down Expand Up @@ -226,39 +272,36 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do
"MediaSelectorModal toggle_selection: mode=#{inspect(mode)}, file_uuid=#{file_uuid}"
)

new_selected_uuids =
# `update/2` normalizes :mode, so only the two atoms reach here. Appending
# (not set-insertion) keeps the user's pick order — see update/2.
{new_selected_uuids, limit_hit} =
case mode do
:single ->
MapSet.new([file_uuid])

:multiple ->
if MapSet.member?(selected_uuids, file_uuid) do
MapSet.delete(selected_uuids, file_uuid)
else
MapSet.put(selected_uuids, file_uuid)
end
cond do
file_uuid in selected_uuids ->
{List.delete(selected_uuids, file_uuid), false}

# Handle string versions in case they come through as strings
"single" ->
MapSet.new([file_uuid])
selection_at_cap?(selected_uuids, socket.assigns.max_select) ->
# At the cap: reject the add and surface the limit instead of
# letting the consumer silently truncate on confirm.
{selected_uuids, true}

"multiple" ->
if MapSet.member?(selected_uuids, file_uuid) do
MapSet.delete(selected_uuids, file_uuid)
else
MapSet.put(selected_uuids, file_uuid)
true ->
{selected_uuids ++ [file_uuid], false}
end

# Default to single select for any unexpected value
_ ->
MapSet.new([file_uuid])
:single ->
{[file_uuid], false}
end

{:noreply, assign(socket, :selected_uuids, new_selected_uuids)}
{:noreply,
socket
|> assign(:selected_uuids, new_selected_uuids)
|> assign(:limit_hit, limit_hit)}
end

def handle_event("confirm_selection", _params, socket) do
selected_uuids = socket.assigns.selected_uuids |> MapSet.to_list()
selected_uuids = socket.assigns.selected_uuids

case socket.assigns[:notify] do
{module, id} ->
Expand Down Expand Up @@ -309,6 +352,7 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do
socket
|> assign(:file_type_filter, parsed_filter)
|> assign(:current_page, 1)
|> refresh_upload_accept(parsed_filter)

{files, total_count} = load_files(socket, 1)
total_pages = ceil(total_count / socket.assigns.per_page)
Expand All @@ -323,7 +367,14 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do
end

def handle_event("change_page", %{"page" => page}, socket) do
page = String.to_integer(page)
# The payload is client-controlled — a malformed value must not crash the
# host LiveView (this component runs inside the caller's process).
page =
case Integer.parse(to_string(page)) do
{n, ""} when n > 0 -> n
_ -> 1
end

{files, total_count} = load_files(socket, page)
total_pages = ceil(total_count / socket.assigns.per_page)

Expand Down Expand Up @@ -387,8 +438,17 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do

selected_uuids =
case socket.assigns.mode do
:single -> MapSet.new([file_uuid])
:multiple -> MapSet.put(socket.assigns.selected_uuids, file_uuid)
:single ->
[file_uuid]

:multiple ->
# Respect the cap: an upload past it still stores the file
# (it appears in the grid), it just isn't auto-selected.
if selection_at_cap?(socket.assigns.selected_uuids, socket.assigns.max_select) do
socket.assigns.selected_uuids
else
Enum.uniq(socket.assigns.selected_uuids ++ [file_uuid])
end
end

socket
Expand Down Expand Up @@ -627,6 +687,28 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do
defp parse_filter("all"), do: :all
defp parse_filter(_), do: :all

# Callers pass :single / :multiple; tolerate the string forms and default
# anything else to :single so downstream `case` clauses stay total.
defp normalize_mode(:multiple), do: :multiple
defp normalize_mode("multiple"), do: :multiple
defp normalize_mode(_), do: :single

# Seeded selections keep their order but drop duplicates; a :single picker
# keeps only the first uuid — otherwise a multi-uuid seed lets Confirm
# return several files from a single-select modal. A :multiple picker with
# a max_select cap clamps the seed the same way.
defp normalize_selection(uuids, :single, _max), do: uuids |> Enum.uniq() |> Enum.take(1)

defp normalize_selection(uuids, _mode, max) when is_integer(max) and max > 0,
do: uuids |> Enum.uniq() |> Enum.take(max)

defp normalize_selection(uuids, _mode, _max), do: Enum.uniq(uuids)

defp selection_at_cap?(selected, max) when is_integer(max) and max > 0,
do: length(selected) >= max

defp selection_at_cap?(_selected, _max), do: false

defp format_file_size(bytes), do: Format.bytes(bytes, decimals: 2, unknown: "0 B")

defp pagination_range(current_page, total_pages) do
Expand Down
Loading