Packages

phoenix_kit

1.7.120
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_browser.ex
Raw

lib/phoenix_kit_web/components/media_browser.ex

defmodule PhoenixKitWeb.Components.MediaBrowser do
@moduledoc """
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}
/>
## 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
## Required attributes
- `id` — unique DOM id (required by LiveComponent)
## Optional attributes
- `phoenix_kit_current_user` — logged-in user struct (for upload attribution)
- `scope_folder_id` — constrain the browser to a virtual root folder
- `on_navigate` — when truthy, enables controlled mode (URL-sync via parent)
- `admin` — when `true`, clicking a file opens the admin detail page at
`/admin/media/:uuid`. When `false` (default), clicks open a read-only
in-place modal viewer (image / video / PDF / icon + metadata + Download
+ prev/next navigation). Bulk-select is still reachable via the
toolbar's Select button — once `select_mode` is on, clicks toggle
selection instead of opening the modal.
"""
use PhoenixKitWeb, :live_component
require Logger
import Ecto.Query
import PhoenixKitWeb.Components.FolderExplorer,
only: [
folder_explorer: 1,
folder_color_hex: 1,
folder_icon_style: 1,
folder_bg_style: 1
]
alias Phoenix.LiveView.JS
alias PhoenixKit.Modules.Storage
alias PhoenixKit.Modules.Storage.FileInstance
alias PhoenixKit.Modules.Storage.URLSigner
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
alias PhoenixKit.Utils.Format
alias PhoenixKit.Utils.Routes
# ──────────────────────────────────────────────────────────────
# Lifecycle
# ──────────────────────────────────────────────────────────────
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:scope_folder_id, fn -> nil end)
|> assign_new(:admin, fn -> false end)
|> assign_new(:viewer_file, fn -> nil end)
cond do
not Map.has_key?(socket.assigns, :uploaded_files) ->
socket = init_socket(socket)
# Register this component id with the parent so it can route uploads here
send(self(), {__MODULE__, :register_component, socket.assigns.id})
# Apply initial params if provided (avoids flash of root before correct view)
socket =
if Map.has_key?(assigns, :initial_params),
do: apply_nav_params(socket, assigns.initial_params),
else: socket
{:ok, socket}
Map.has_key?(assigns, :nav_params) ->
{:ok, apply_nav_params(socket, socket.assigns.nav_params)}
Map.has_key?(assigns, :pending_upload) ->
{:ok, process_pending_upload(socket, assigns.pending_upload)}
Map.get(assigns, :action) == :commit_upload_batch ->
{:ok, commit_upload_batch(socket)}
true ->
{:ok, socket}
end
end
# Processes one entry and buffers the result. A single reload + flash is
# committed from commit_upload_batch/1 once the batch debounce window closes,
# so dropping N files produces one page reload instead of N.
defp process_pending_upload(socket, {path, entry}) do
result = process_single_upload(socket, path, entry)
File.rm(path)
batch = [result | socket.assigns[:pending_batch] || []]
new_uuids =
case result do
{:ok, %{file_uuid: uuid}} -> [uuid | socket.assigns.last_uploaded_file_uuids]
_ -> socket.assigns.last_uploaded_file_uuids
end
socket =
socket
|> assign(:pending_batch, batch)
|> assign(:last_uploaded_file_uuids, new_uuids)
if socket.assigns[:batch_scheduled] do
socket
else
Phoenix.LiveView.send_update_after(
__MODULE__,
[id: socket.assigns.id, action: :commit_upload_batch],
250
)
assign(socket, :batch_scheduled, true)
end
end
defp commit_upload_batch(socket) do
batch = socket.assigns[:pending_batch] || []
{flash_type, flash_msg} = build_upload_flash_message(Enum.reverse(batch))
socket
|> assign(:pending_batch, [])
|> assign(:batch_scheduled, false)
|> reload_current_page()
|> put_flash(flash_type, flash_msg)
end
defp apply_nav_params(socket, params) do
q = params[:q] || ""
page = params[:page] || 1
filter_orphaned = params[:filter_orphaned] || false
file_view = params[:view]
scope = scope_folder_id(socket)
per_page = socket.assigns.per_page
{current_folder, breadcrumbs, folders, scoped_fallback?} =
resolve_folder(params[:folder], scope)
actual_uuid = current_folder && current_folder.uuid
{files, total_count} =
load_nav_files(scope, page, per_page, q, actual_uuid, filter_orphaned, file_view)
orphaned_count =
if filter_orphaned,
do: total_count,
else: Storage.count_orphaned_files(scope)
socket
|> assign(:current_folder, current_folder)
|> assign(:breadcrumbs, breadcrumbs)
|> assign(:folders, if(file_view == "all", do: [], else: folders))
|> assign(:search_query, q)
|> assign(:current_page, page)
|> assign(:filter_orphaned, filter_orphaned)
|> assign(:filter_trash, false)
|> assign(:file_view, file_view)
|> assign(:orphaned_count, orphaned_count)
|> assign(:trash_count, full_trash_count(scope_folder_id(socket)))
|> assign(:uploaded_files, files)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / per_page))
|> then(
&if(scoped_fallback?,
do: put_flash(&1, :info, gettext("Folder not accessible — showing root")),
else: &1
)
)
|> auto_expand_breadcrumbs(breadcrumbs)
end
defp resolve_folder(folder_uuid, scope) do
if folder_uuid in [nil, ""] do
{nil, [], Storage.list_folders(nil, scope), false}
else
folder = Storage.get_folder(folder_uuid)
if folder && Storage.within_scope?(folder.uuid, scope) do
bc = Storage.folder_breadcrumbs(folder.uuid, scope)
flds = Storage.list_folders(folder.uuid, scope)
{folder, bc, flds, false}
else
# Folder not found or outside scope — fall back to scope root.
{nil, [], Storage.list_folders(nil, scope), not is_nil(folder_uuid)}
end
end
end
defp load_nav_files(scope, page, per_page, q, actual_uuid, filter_orphaned, file_view) do
cond do
filter_orphaned -> load_orphaned_files(page, per_page)
file_view == "all" -> load_all_view_files(scope, page, per_page, q)
true -> load_scoped_files(scope, page, per_page, actual_uuid, q)
end
end
defp init_socket(socket) do
scope = scope_folder_id(socket)
scope_folder = if scope, do: Storage.get_folder(scope)
scope_invalid = not is_nil(scope) and is_nil(scope_folder)
enabled_buckets = Storage.list_enabled_buckets()
has_buckets = not Enum.empty?(enabled_buckets)
scope_name = if scope_folder, do: scope_folder.name, else: "Root"
socket
|> maybe_allow_upload(has_buckets)
|> assign(:has_buckets, has_buckets)
|> assign(:scope_invalid, scope_invalid)
|> assign(:scope_folder_name, scope_name)
|> assign(:show_upload, false)
|> assign(:show_search, false)
|> assign(:last_uploaded_file_uuids, [])
|> assign(:pending_batch, [])
|> assign(:batch_scheduled, false)
|> assign(:filter_orphaned, false)
|> assign(:filter_trash, false)
|> assign(:trash_count, full_trash_count(scope_folder_id(socket)))
|> assign(:file_view, nil)
|> assign(
:orphaned_count,
if(scope_invalid, do: 0, else: Storage.count_orphaned_files(scope))
)
|> assign(:current_folder, nil)
|> assign(:breadcrumbs, [])
|> assign(:folders, if(scope_invalid, do: [], else: Storage.list_folders(nil, scope)))
|> assign(:folder_tree, if(scope_invalid, do: [], else: Storage.list_folder_tree(scope)))
|> assign(:sidebar_collapsed, false)
|> assign(:expanded_folders, MapSet.new())
|> assign(:renaming_folder, nil)
|> assign(:renaming_source, nil)
|> assign(:renaming_text, "")
|> assign(:view_mode, "grid")
|> assign(:search_query, "")
|> assign(:select_mode, false)
|> assign(:selected_files, MapSet.new())
|> assign(:selected_folders, MapSet.new())
|> assign(:show_move_modal, false)
|> assign(:current_page, 1)
|> assign(:per_page, 50)
|> then(fn s ->
if scope_invalid,
do: assign(s, uploaded_files: [], total_count: 0, total_pages: 0),
else: reload_current_page(s)
end)
end
# ──────────────────────────────────────────────────────────────
# Upload
# ──────────────────────────────────────────────────────────────
@doc """
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
"""
def setup_uploads(socket) do
if socket.assigns[:uploads] do
socket
else
max_size_mb =
Settings.get_setting_cached("storage_max_upload_size_mb", "500")
|> String.to_integer()
socket
|> assign(:max_upload_size_mb, max_size_mb)
|> allow_upload(:media_files,
accept: :any,
max_entries: 10,
max_file_size: max_size_mb * 1_000_000,
auto_upload: true,
progress: &__MODULE__.parent_progress/3
)
end
end
@doc false
# Progress callback that runs on the parent LiveView socket.
# Consumes the upload and sends the file to the MediaBrowser component for processing.
def parent_progress(:media_files, entry, socket) do
if entry.done? do
# Persist file to a temp path since consume_uploaded_entry cleans up the original
persistent_path = Path.join(System.tmp_dir!(), "pk_upload_#{entry.uuid}")
result =
consume_uploaded_entry(socket, entry, fn %{path: path} ->
File.cp!(path, persistent_path)
{:ok, :done}
end)
if result == :done do
# Payload is wrapped in a tuple so the outer message stays a 3-tuple;
# the Embed macro's handle_info matches `{Mod, _, _}` only.
send(self(), {__MODULE__, :process_pending_upload, {persistent_path, entry}})
end
end
{:noreply, socket}
end
@doc "Catch-all handler for parent LiveViews to delegate MediaBrowser messages."
def handle_parent_info({__MODULE__, :register_component, id}, socket) do
ids = MapSet.put(socket.assigns[:media_browser_ids] || MapSet.new(), id)
{:noreply, assign(socket, :media_browser_ids, ids)}
end
def handle_parent_info({__MODULE__, :process_pending_upload, {path, entry}}, socket) do
# Forward the upload to every registered MediaBrowser. If more than one is on
# the page, copy the temp file per recipient so each component owns its own
# path — otherwise the first one to run would delete the shared file and the
# rest would crash on `File.stat/1`.
component_ids =
socket.assigns[:media_browser_ids]
|> Kernel.||(MapSet.new())
|> MapSet.to_list()
case component_ids do
[] ->
File.rm(path)
[single] ->
send_update(__MODULE__, id: single, pending_upload: {path, entry})
[first | rest] ->
send_update(__MODULE__, id: first, pending_upload: {path, entry})
Enum.each(rest, fn id ->
copy = "#{path}-#{id}"
case File.cp(path, copy) do
:ok -> send_update(__MODULE__, id: id, pending_upload: {copy, entry})
_ -> :ok
end
end)
end
{:noreply, socket}
end
def handle_parent_info(_msg, socket), do: {:noreply, socket}
defp maybe_allow_upload(socket, has_buckets) do
if has_buckets do
max_size_mb =
Settings.get_setting_cached("storage_max_upload_size_mb", "500")
|> String.to_integer()
assign(socket, :max_upload_size_mb, max_size_mb)
else
assign(socket, :max_upload_size_mb, 0)
end
end
@doc false
def handle_progress(:media_files, entry, socket) do
socket =
if entry.done? do
result =
consume_uploaded_entry(socket, entry, fn %{path: path} ->
process_single_upload(socket, path, entry)
end)
{flash_type, flash_msg} = build_upload_flash_message([result])
new_uuids =
case result do
{:ok, %{file_uuid: uuid}} -> [uuid | socket.assigns.last_uploaded_file_uuids]
_ -> socket.assigns.last_uploaded_file_uuids
end
socket
|> reload_current_page()
|> put_flash(flash_type, flash_msg)
|> assign(:last_uploaded_file_uuids, new_uuids)
else
socket
end
{:noreply, socket}
end
# ──────────────────────────────────────────────────────────────
# Function components
# ──────────────────────────────────────────────────────────────
attr :node, :map, required: true
attr :depth, :integer, default: 0
attr :myself, :any, required: true
def move_folder_option(assigns) do
~H"""
<li>
<button
phx-click="move_selected_to_folder"
phx-target={@myself}
phx-value-folder_uuid={@node.folder.uuid}
style={"padding-left: #{(@depth + 1) * 16}px"}
>
<.icon name="hero-folder" class="w-4 h-4" /> {@node.folder.name}
</button>
<%= for child <- @node.children do %>
<.move_folder_option node={child} depth={@depth + 1} myself={@myself} />
<% end %>
</li>
"""
end
# ──────────────────────────────────────────────────────────────
# Event handlers
# ──────────────────────────────────────────────────────────────
# Finder/Explorer-style instant folder creation. Click the "+" button
# in any toolbar and a folder appears named "untitled" (or
# "untitled 1", "untitled 2", ... if that's taken). User can rename
# via the kebab → Rename action afterward.
def handle_event("create_untitled_folder", _params, socket) do
cond do
socket.assigns[:filter_trash] ->
{:noreply, put_flash(socket, :error, gettext("Cannot create folders in trash"))}
socket.assigns[:file_view] == "all" ->
{:noreply,
put_flash(socket, :error, gettext("Cannot create folders in the all-files view"))}
true ->
parent_uuid = current_folder_uuid(socket)
scope = scope_folder_id(socket)
user = socket.assigns[:phoenix_kit_current_user]
name = next_untitled_name(parent_uuid, scope)
case Storage.create_folder(
%{name: name, parent_uuid: parent_uuid, user_uuid: user && user.uuid},
scope
) do
{:ok, _folder} ->
{:noreply,
socket
|> reload_folder_lists()
|> put_flash(:info, gettext("Folder \"%{name}\" created", name: name))}
{:error, :out_of_scope} ->
{:noreply,
put_flash(socket, :error, gettext("Cannot create folder outside the allowed scope"))}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, gettext("Failed to create folder"))}
end
end
end
def handle_event("delete_folder", %{"id" => folder_uuid}, socket) do
folder = Storage.get_folder(folder_uuid)
scope = scope_folder_id(socket)
if folder do
# Mirror the file kebab: trash-recursive when outside the trash
# view, permanent-recursive (with storage cleanup) when inside.
# Subtree files come along automatically — both helpers walk
# descendants via `Storage.folder_subtree_uuids/1` and apply the
# operation atomically.
result =
if socket.assigns[:filter_trash] do
Storage.delete_folder_completely(folder, scope)
else
Storage.trash_folder(folder, scope)
end
flash =
if socket.assigns[:filter_trash],
do: gettext("Folder permanently deleted"),
else: gettext("Folder moved to trash")
case result do
{:error, :out_of_scope} ->
{:noreply,
put_flash(socket, :error, gettext("Cannot delete folder outside the allowed scope"))}
_ ->
{:noreply,
socket
|> reload_folder_lists()
|> reload_current_page()
|> put_flash(:info, flash)}
end
else
{:noreply, put_flash(socket, :error, gettext("Folder not found"))}
end
end
def handle_event(
"move_file_to_folder",
%{"file_uuid" => file_uuid, "folder_uuid" => folder_uuid},
socket
) do
scope = scope_folder_id(socket)
# "root" in a scoped browser is the scope folder, not nil. Passing nil
# would mean the system's true root, which is outside the scope —
# `move_file_to_folder/3` would reject it with `:out_of_scope`.
target = if folder_uuid == "", do: scope, else: folder_uuid
case Storage.move_file_to_folder(file_uuid, target, scope) do
{:ok, _} ->
{:noreply, socket |> put_flash(:info, gettext("File moved")) |> reload_current_page()}
{:error, :out_of_scope} ->
{:noreply,
put_flash(socket, :error, gettext("Cannot move file outside the allowed scope"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to move file"))}
end
end
# Drag a folder onto another folder (or the root drop target) to
# reparent it. Mirrors `move_file_to_folder` but uses
# `Storage.update_folder/3`, which enforces the cycle check (can't
# move a folder into its own descendant). JS pre-empts the
# drop-on-self case so we don't see it here.
def handle_event(
"move_folder_to_folder",
%{"folder_uuid" => folder_uuid, "target_uuid" => target_uuid},
socket
) do
scope = scope_folder_id(socket)
target = if target_uuid == "", do: scope, else: target_uuid
case Storage.get_folder(folder_uuid) do
nil ->
{:noreply, put_flash(socket, :error, gettext("Folder not found"))}
folder ->
case Storage.update_folder(folder, %{parent_uuid: target}, scope) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, gettext("Folder moved"))
|> reload_folder_lists()
|> reload_current_page()}
{:error, :out_of_scope} ->
{:noreply,
put_flash(socket, :error, gettext("Cannot move folder outside the allowed scope"))}
{:error, :cycle} ->
{:noreply,
put_flash(socket, :error, gettext("Cannot move a folder into its own descendant"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to move folder"))}
end
end
end
# Drag-to-trash: file dragged onto the sidebar Trash button. Always
# soft-deletes via `Storage.trash_file/1` — the "drop on trash" gesture
# means "put this in the trash", not "permanently destroy". Permanent
# deletion stays explicit (kebab Delete Permanently when already in
# trash view, or the Empty Trash button).
def handle_event("trash_file", %{"file_uuid" => file_uuid}, socket) do
scope = scope_folder_id(socket)
repo = PhoenixKit.Config.get_repo()
file = repo.get(Storage.File, file_uuid)
cond do
is_nil(file) ->
{:noreply, put_flash(socket, :error, gettext("File not found"))}
not Storage.within_scope?(file.folder_uuid, scope) ->
{:noreply,
put_flash(socket, :error, gettext("Cannot move file outside the allowed scope"))}
true ->
Storage.trash_file(file)
{:noreply,
socket
|> put_flash(:info, gettext("File moved to trash"))
|> reload_current_page()}
end
end
# Drag-to-trash for a single folder. Mirrors `trash_file` above but
# uses `Storage.trash_folder/2`, which recursively trashes the folder
# and every descendant + file inside the subtree. Always soft-delete
# — permanent deletion stays explicit (kebab Delete Permanently when
# already in the trash view, or bulk select + delete).
def handle_event("trash_folder", %{"folder_uuid" => folder_uuid}, socket) do
scope = scope_folder_id(socket)
folder = Storage.get_folder(folder_uuid)
cond do
is_nil(folder) ->
{:noreply, put_flash(socket, :error, gettext("Folder not found"))}
not Storage.within_scope?(folder.uuid, scope) ->
{:noreply,
put_flash(socket, :error, gettext("Cannot move folder outside the allowed scope"))}
true ->
case Storage.trash_folder(folder, scope) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, gettext("Folder moved to trash"))
|> reload_folder_lists()
|> reload_current_page()}
{:error, :out_of_scope} ->
{:noreply,
put_flash(socket, :error, gettext("Cannot move folder outside the allowed scope"))}
_ ->
{:noreply, put_flash(socket, :error, gettext("Failed to move folder to trash"))}
end
end
end
def handle_event("search", %{"q" => query}, socket) do
if controlled_mode?(socket) do
folder_uuid = current_folder_uuid(socket)
send(
self(),
{__MODULE__, socket.assigns.id,
{:navigate, %{folder: folder_uuid, q: query, page: 1, view: socket.assigns.file_view}}}
)
{:noreply, socket}
else
folder_uuid = current_folder_uuid(socket)
per_page = socket.assigns.per_page
scope = scope_folder_id(socket)
file_view = socket.assigns.file_view
{files, total_count} =
if file_view == "all" do
load_all_view_files(scope, 1, per_page, query)
else
load_scoped_files(scope, 1, per_page, folder_uuid, query)
end
folders =
cond do
file_view == "all" -> []
query != "" -> []
true -> Storage.list_folders(folder_uuid, scope)
end
{:noreply,
socket
|> assign(:search_query, query)
|> assign(:folders, folders)
|> assign(:uploaded_files, files)
|> assign(:current_page, 1)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / per_page))}
end
end
def handle_event("clear_search", _params, socket) do
if controlled_mode?(socket) do
folder_uuid = current_folder_uuid(socket)
send(
self(),
{__MODULE__, socket.assigns.id,
{:navigate, %{folder: folder_uuid, q: "", page: 1, view: socket.assigns.file_view}}}
)
{:noreply, socket}
else
folder_uuid = current_folder_uuid(socket)
per_page = socket.assigns.per_page
scope = scope_folder_id(socket)
file_view = socket.assigns.file_view
{files, total_count} =
if file_view == "all" do
load_all_view_files(scope, 1, per_page, "")
else
load_scoped_files(scope, 1, per_page, folder_uuid, "")
end
folders =
if file_view == "all",
do: [],
else: Storage.list_folders(folder_uuid, scope)
{:noreply,
socket
|> assign(:search_query, "")
|> assign(:folders, folders)
|> assign(:uploaded_files, files)
|> assign(:current_page, 1)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / per_page))}
end
end
def handle_event("noop", _params, socket), do: {:noreply, socket}
def handle_event("navigate_folder", params, socket) do
folder_uuid = params["folder-uuid"] || params["folder_uuid"]
navigate_to_folder(socket, folder_uuid)
end
def handle_event("navigate_root", _params, socket) do
navigate_to_folder(socket, nil)
end
def handle_event("navigate_view_all", _params, socket) do
if controlled_mode?(socket) do
q = socket.assigns.search_query
send(
self(),
{__MODULE__, socket.assigns.id,
{:navigate, %{view: "all", folder: nil, q: q, page: 1, filter_orphaned: false}}}
)
{:noreply, socket}
else
scope = scope_folder_id(socket)
per_page = socket.assigns.per_page
q = socket.assigns.search_query
{files, total_count} = load_all_view_files(scope, 1, per_page, q)
{:noreply,
socket
|> assign(:file_view, "all")
|> assign(:current_folder, nil)
|> assign(:breadcrumbs, [])
|> assign(:folders, [])
|> assign(:filter_orphaned, false)
|> assign(:filter_trash, false)
|> assign(:uploaded_files, files)
|> assign(:current_page, 1)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / per_page))}
end
end
def handle_event("set_view_mode", %{"mode" => mode}, socket) when mode in ["grid", "list"] do
{:noreply, assign(socket, :view_mode, mode)}
end
def handle_event("toggle_sidebar", _params, socket) do
socket = assign(socket, :sidebar_collapsed, !socket.assigns.sidebar_collapsed)
{:noreply, push_tree_state(socket)}
end
def handle_event("restore_tree_state", params, socket) do
expanded = (params["expanded"] || []) |> MapSet.new()
sidebar_collapsed = params["sidebar_collapsed"] == true
{:noreply,
socket
|> assign(:expanded_folders, MapSet.union(socket.assigns.expanded_folders, expanded))
|> assign(:sidebar_collapsed, sidebar_collapsed)}
end
def handle_event("start_rename_folder", %{"folder-uuid" => folder_uuid} = params, socket) do
source = params["source"] || "content"
folder = Storage.get_folder(folder_uuid)
{:noreply,
socket
|> assign(:renaming_folder, folder_uuid)
|> assign(:renaming_source, source)
|> assign(:renaming_text, (folder && folder.name) || "")}
end
def handle_event("rename_folder_input", %{"name" => name}, socket) do
{:noreply, assign(socket, :renaming_text, name)}
end
def handle_event("cancel_rename_folder", _params, socket) do
{:noreply,
socket
|> assign(:renaming_folder, nil)
|> assign(:renaming_source, nil)
|> assign(:renaming_text, "")}
end
def handle_event("rename_folder", %{"folder_uuid" => folder_uuid, "name" => name}, socket) do
folder = Storage.get_folder(folder_uuid)
scope = scope_folder_id(socket)
if folder && name != "" do
case Storage.update_folder(folder, %{name: String.trim(name)}, scope) do
{:ok, _} ->
parent_uuid = current_folder_uuid(socket)
{:noreply,
socket
|> assign(:renaming_folder, nil)
|> assign(:renaming_source, nil)
|> assign(:renaming_text, "")
|> assign(:folders, Storage.list_folders(parent_uuid, scope))
|> assign(:folder_tree, Storage.list_folder_tree(scope))}
{:error, :out_of_scope} ->
{:noreply,
put_flash(socket, :error, gettext("Cannot rename folder outside the allowed scope"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to rename folder"))}
end
else
{:noreply,
socket
|> assign(:renaming_folder, nil)
|> assign(:renaming_source, nil)
|> assign(:renaming_text, "")}
end
end
def handle_event(
"change_folder_color",
%{"folder-uuid" => folder_uuid, "color" => color},
socket
) do
folder = Storage.get_folder(folder_uuid)
scope = scope_folder_id(socket)
if folder do
case Storage.update_folder(folder, %{color: color}, scope) do
{:ok, _} ->
parent_uuid = current_folder_uuid(socket)
{:noreply,
socket
|> assign(:folders, Storage.list_folders(parent_uuid, scope))
|> assign(:folder_tree, Storage.list_folder_tree(scope))}
{:error, :out_of_scope} ->
{:noreply,
put_flash(socket, :error, gettext("Cannot change folder outside the allowed scope"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to change color"))}
end
else
{:noreply, socket}
end
end
def handle_event("toggle_folder_expand", %{"folder-uuid" => folder_uuid}, socket) do
expanded = socket.assigns.expanded_folders
expanded =
if MapSet.member?(expanded, folder_uuid),
do: MapSet.delete(expanded, folder_uuid),
else: MapSet.put(expanded, folder_uuid)
socket = assign(socket, :expanded_folders, expanded)
{:noreply, push_tree_state(socket)}
end
def handle_event("toggle_select_mode", _params, socket) do
if socket.assigns.select_mode do
{:noreply,
socket
|> assign(:select_mode, false)
|> assign(:selected_files, MapSet.new())
|> assign(:selected_folders, MapSet.new())}
else
{:noreply, assign(socket, :select_mode, true)}
end
end
def handle_event("click_file", %{"file-uuid" => file_uuid}, socket) do
cond do
# Already in selection mode → toggle this file in/out, stay in selection mode.
# The toolbar's "Select" button is how callers reach bulk-select; once on,
# clicks add/remove rather than open the modal.
socket.assigns.select_mode ->
{:noreply, do_toggle_file(socket, file_uuid)}
# Admin context → navigate to the rich admin detail page.
socket.assigns.admin ->
{:noreply, push_navigate(socket, to: Routes.path("/admin/media/#{file_uuid}"))}
# Anywhere else (non-admin, not in select mode) → open the in-place
# modal viewer for that file. This is the default and is what every
# embedded MediaBrowser gets unless `admin={true}` is set.
true ->
{:noreply, open_viewer(socket, find_uploaded_file(socket, file_uuid))}
end
end
def handle_event("close_viewer", _params, socket) do
{:noreply, open_viewer(socket, nil)}
end
# Single keydown router so we can handle multiple keys without stacking
# phx-window-keydown directives (only one fires per element).
def handle_event("viewer_keydown", %{"key" => "Escape"}, socket) do
{:noreply, open_viewer(socket, nil)}
end
def handle_event("viewer_keydown", %{"key" => "ArrowLeft"}, socket) do
{:noreply, step_viewer(socket, :prev)}
end
def handle_event("viewer_keydown", %{"key" => "ArrowRight"}, socket) do
{:noreply, step_viewer(socket, :next)}
end
def handle_event("viewer_keydown", _params, socket), do: {:noreply, socket}
def handle_event("step_viewer", %{"dir" => "prev"}, socket) do
{:noreply, step_viewer(socket, :prev)}
end
def handle_event("step_viewer", %{"dir" => "next"}, socket) do
{:noreply, step_viewer(socket, :next)}
end
# Etcher annotation events + composer lifecycle are owned by the
# MediaCanvasViewer child LiveComponent embedded in the viewer
# modal — events fire there, not here. See
# `lib/phoenix_kit_web/components/media_canvas_viewer.ex`.
def handle_event("toggle_select_folder", %{"folder-uuid" => folder_uuid}, socket) do
selected = socket.assigns.selected_folders
selected =
if MapSet.member?(selected, folder_uuid),
do: MapSet.delete(selected, folder_uuid),
else: MapSet.put(selected, folder_uuid)
{:noreply, assign(socket, :selected_folders, selected)}
end
def handle_event("toggle_select", %{"file-uuid" => file_uuid}, socket) do
selected = socket.assigns.selected_files
selected =
if MapSet.member?(selected, file_uuid),
do: MapSet.delete(selected, file_uuid),
else: MapSet.put(selected, file_uuid)
{:noreply, assign(socket, :selected_files, selected)}
end
def handle_event("select_all", _params, socket) do
all_file_uuids = Enum.map(socket.assigns.uploaded_files, & &1.file_uuid)
all_folder_uuids = Enum.map(socket.assigns.folders, & &1.uuid)
{:noreply,
socket
|> assign(:selected_files, MapSet.new(all_file_uuids))
|> assign(:selected_folders, MapSet.new(all_folder_uuids))}
end
def handle_event("deselect_all", _params, socket) do
{:noreply,
socket
|> assign(:select_mode, false)
|> assign(:selected_files, MapSet.new())
|> assign(:selected_folders, MapSet.new())}
end
def handle_event("show_move_modal", _params, socket) do
{:noreply, assign(socket, :show_move_modal, true)}
end
def handle_event("close_move_modal", _params, socket) do
# Clear the transient single-item selection when the cancel came from
# a kebab-triggered move. Bulk-mode users may want their selection to
# persist across a cancel (so they can retry without reselecting), so
# only clear when select_mode is off — which is also when the kebabs
# are visible, since we hide them in select_mode.
socket = assign(socket, :show_move_modal, false)
socket =
if socket.assigns.select_mode do
socket
else
socket
|> assign(:selected_files, MapSet.new())
|> assign(:selected_folders, MapSet.new())
end
{:noreply, socket}
end
# Single-file move from kebab — pre-populates the selection with just
# this file and opens the move modal. Reuses the bulk
# `move_selected_to_folder` flow without entering select_mode.
def handle_event("prepare_move_file", %{"file-uuid" => file_uuid}, socket) do
{:noreply,
socket
|> assign(:selected_files, MapSet.new([file_uuid]))
|> assign(:selected_folders, MapSet.new())
|> assign(:show_move_modal, true)}
end
# Single-folder move from kebab — symmetric to `prepare_move_file`.
def handle_event("prepare_move_folder", %{"folder-uuid" => folder_uuid}, socket) do
{:noreply,
socket
|> assign(:selected_files, MapSet.new())
|> assign(:selected_folders, MapSet.new([folder_uuid]))
|> assign(:show_move_modal, true)}
end
def handle_event("move_selected_to_folder", %{"folder_uuid" => folder_uuid}, socket) do
scope = scope_folder_id(socket)
# "root" in a scoped browser is the scope folder, not nil — same fix as
# the drag-drop `move_file_to_folder` handler above. Without this,
# picking the home/root option in the move modal silently failed for
# files (`:out_of_scope`) and silently *escaped* the scope for folders
# (the scoped `update_folder` skips the parent-scope check when the
# new parent is nil).
target = if folder_uuid == "", do: scope, else: folder_uuid
Enum.each(socket.assigns.selected_files, fn file_uuid ->
Storage.move_file_to_folder(file_uuid, target, scope)
end)
Enum.each(socket.assigns.selected_folders, fn sel_folder_uuid ->
if sel_folder_uuid != target do
folder = Storage.get_folder(sel_folder_uuid)
if folder, do: Storage.update_folder(folder, %{parent_uuid: target}, scope)
end
end)
file_count = MapSet.size(socket.assigns.selected_files)
folder_count = MapSet.size(socket.assigns.selected_folders)
{:noreply,
socket
|> assign(:select_mode, false)
|> assign(:selected_files, MapSet.new())
|> assign(:selected_folders, MapSet.new())
|> assign(:show_move_modal, false)
|> put_flash(
:info,
ngettext("%{count} item moved", "%{count} items moved", file_count + folder_count)
)
|> reload_folder_lists()
|> reload_current_page()}
end
def handle_event("delete_selected", _params, socket) do
scope = scope_folder_id(socket)
repo = PhoenixKit.Config.get_repo()
if socket.assigns.filter_trash do
# Permanent delete from trash (with scope guard)
Enum.each(socket.assigns.selected_files, fn file_uuid ->
file = repo.get(Storage.File, file_uuid)
if file && Storage.within_scope?(file.folder_uuid, scope) do
Storage.delete_file_completely(file)
end
end)
else
# Soft-delete to trash
Enum.each(socket.assigns.selected_files, fn file_uuid ->
file = repo.get(Storage.File, file_uuid)
if file && Storage.within_scope?(file.folder_uuid, scope) do
Storage.trash_file(file)
end
end)
end
# Folders now have trash support (V119) — recursive trash outside
# the trash view, recursive permanent delete inside. Both operations
# cover the folder's entire subtree (descendant folders + files).
Enum.each(socket.assigns.selected_folders, fn folder_uuid ->
folder = Storage.get_folder(folder_uuid)
if folder do
if socket.assigns.filter_trash do
Storage.delete_folder_completely(folder, scope)
else
Storage.trash_folder(folder, scope)
end
end
end)
file_count = MapSet.size(socket.assigns.selected_files)
folder_count = MapSet.size(socket.assigns.selected_folders)
flash =
if socket.assigns.filter_trash,
do: "#{file_count + folder_count} item(s) permanently deleted",
else: "#{file_count + folder_count} item(s) moved to trash"
{:noreply,
socket
|> assign(:select_mode, false)
|> assign(:selected_files, MapSet.new())
|> assign(:selected_folders, MapSet.new())
|> put_flash(:info, flash)
|> reload_folder_lists()
|> reload_current_page()}
end
def handle_event("download_selected", _params, socket) do
files =
socket.assigns.uploaded_files
|> Enum.filter(&MapSet.member?(socket.assigns.selected_files, &1.file_uuid))
|> Enum.map(fn f ->
%{url: Map.get(f.urls, "original") || Map.get(f.urls, :original), name: f.filename}
end)
|> Enum.reject(&is_nil(&1.url))
{:noreply, push_event(socket, "download_files", %{files: files})}
end
# Single-file delete via the per-row kebab menu. Mirrors `delete_selected`
# for one file — soft-delete to trash when outside the trash view, permanent
# delete when already inside it. Scope-guarded the same way.
def handle_event("delete_file", %{"file-uuid" => file_uuid}, socket) do
scope = scope_folder_id(socket)
repo = PhoenixKit.Config.get_repo()
file = repo.get(Storage.File, file_uuid)
if file && Storage.within_scope?(file.folder_uuid, scope) do
if socket.assigns.filter_trash do
Storage.delete_file_completely(file)
else
Storage.trash_file(file)
end
flash =
if socket.assigns.filter_trash,
do: gettext("File permanently deleted"),
else: gettext("File moved to trash")
{:noreply,
socket
|> put_flash(:info, flash)
|> reload_current_page()}
else
{:noreply, socket}
end
end
# Single-file download via the per-row kebab menu. Pushes the same
# `download_files` event the JS hook listens for, with one entry. The heex
# only renders the menu item when an "original" URL is present, so a
# missing URL here is a stale assigns / race condition — silent no-op is
# the right call.
def handle_event("download_file", %{"file-uuid" => file_uuid}, socket) do
file = Enum.find(socket.assigns.uploaded_files, &(&1.file_uuid == file_uuid))
url = file && (Map.get(file.urls || %{}, "original") || Map.get(file.urls || %{}, :original))
if url do
{:noreply,
push_event(socket, "download_files", %{files: [%{url: url, name: file.filename}]})}
else
{:noreply, socket}
end
end
def handle_event("toggle_trash_filter", _params, socket) do
filter_trash = !socket.assigns.filter_trash
scope = scope_folder_id(socket)
{files, total_count} =
if filter_trash do
load_trashed_files(scope, 1, socket.assigns.per_page)
else
folder_uuid = current_folder_uuid(socket)
load_scoped_files(scope, 1, socket.assigns.per_page, folder_uuid, "")
end
# In trash view `@folders` becomes the trashed-folder list so the
# grid/list rows render trashed folders alongside trashed files.
# Outside trash it stays the active folder set.
folders =
if filter_trash, do: Storage.list_trashed_folders(scope), else: socket.assigns.folders
{:noreply,
socket
|> assign(:filter_trash, filter_trash)
|> assign(:filter_orphaned, false)
|> assign(:folders, folders)
|> assign(:uploaded_files, files)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / socket.assigns.per_page))
|> assign(:current_page, 1)
|> assign(:trash_count, full_trash_count(scope))}
end
def handle_event("restore_selected", _params, socket) do
scope = scope_folder_id(socket)
repo = PhoenixKit.Config.get_repo()
restored_files =
Enum.reduce(socket.assigns.selected_files, 0, fn file_uuid, acc ->
file = repo.get(Storage.File, file_uuid)
if file && Storage.within_scope?(file.folder_uuid, scope) do
case Storage.restore_file(file) do
{:ok, _} -> acc + 1
_ -> acc
end
else
acc
end
end)
restored_folders =
Enum.reduce(socket.assigns.selected_folders, 0, fn folder_uuid, acc ->
folder = Storage.get_folder(folder_uuid)
if folder && Storage.within_scope?(folder.uuid, scope) do
case Storage.restore_folder(folder, scope) do
{:ok, _} -> acc + 1
_ -> acc
end
else
acc
end
end)
total = restored_files + restored_folders
{:noreply,
socket
|> assign(:select_mode, false)
|> assign(:selected_files, MapSet.new())
|> assign(:selected_folders, MapSet.new())
|> put_flash(:info, ngettext("%{count} item restored", "%{count} items restored", total))
|> reload_folder_lists()
|> reload_current_page()}
end
def handle_event("empty_trash", _params, socket) do
{:ok, count} = Storage.empty_trash(scope_folder_id(socket))
{:noreply,
socket
|> assign(:filter_trash, false)
|> put_flash(
:info,
ngettext("%{count} file permanently deleted", "%{count} files permanently deleted", count)
)
|> reload_current_page()}
end
def handle_event("toggle_orphan_filter", _params, socket) do
filter_orphaned = !socket.assigns.filter_orphaned
if controlled_mode?(socket) do
folder_uuid = current_folder_uuid(socket)
q = socket.assigns.search_query
send(
self(),
{__MODULE__, socket.assigns.id,
{:navigate,
%{folder: folder_uuid, q: q, page: 1, filter_orphaned: filter_orphaned, view: nil}}}
)
{:noreply, socket}
else
per_page = socket.assigns.per_page
scope = scope_folder_id(socket)
folder_uuid = current_folder_uuid(socket)
search = socket.assigns.search_query
{files, total_count} =
if filter_orphaned do
load_orphaned_files(1, per_page)
else
load_scoped_files(scope, 1, per_page, folder_uuid, search)
end
orphaned_count =
if filter_orphaned,
do: total_count,
else: Storage.count_orphaned_files(scope_folder_id(socket))
{:noreply,
socket
|> assign(:filter_orphaned, filter_orphaned)
|> assign(:uploaded_files, files)
|> assign(:current_page, 1)
|> assign(:total_pages, ceil(total_count / per_page))
|> assign(:total_count, total_count)
|> assign(:orphaned_count, orphaned_count)}
end
end
def handle_event("delete_all_orphaned", _params, socket) do
orphan_uuids = Storage.find_orphaned_files() |> Enum.map(& &1.uuid)
Storage.queue_file_cleanup(orphan_uuids)
{:noreply,
put_flash(
socket,
:info,
ngettext(
"%{count} orphaned file queued for deletion",
"%{count} orphaned files queued for deletion",
length(orphan_uuids)
)
)}
end
def handle_event("toggle_upload", _params, socket) do
{:noreply, assign(socket, :show_upload, !socket.assigns.show_upload)}
end
def handle_event("toggle_search", _params, socket) do
{:noreply, assign(socket, :show_search, !socket.assigns.show_search)}
end
def handle_event("show_upload", _params, socket) do
{:noreply, assign(socket, :show_upload, true)}
end
def handle_event("validate", _params, socket) do
# Upload progress is handled via the progress: &handle_progress/3 callback.
{:noreply, socket}
end
def handle_event("cancel_upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :media_files, ref)}
end
def handle_event("set_page", %{"page" => page_str}, socket) do
page =
case Integer.parse(page_str) do
{n, _} when n > 0 -> n
_ -> 1
end
if controlled_mode?(socket) do
folder_uuid = current_folder_uuid(socket)
q = socket.assigns.search_query
file_view = socket.assigns.file_view
send(
self(),
{__MODULE__, socket.assigns.id,
{:navigate, %{folder: folder_uuid, q: q, page: page, view: file_view}}}
)
{:noreply, socket}
else
folder_uuid = current_folder_uuid(socket)
per_page = socket.assigns.per_page
search = socket.assigns.search_query
scope = scope_folder_id(socket)
file_view = socket.assigns.file_view
{files, total_count} =
cond do
socket.assigns.filter_orphaned -> load_orphaned_files(page, per_page)
file_view == "all" -> load_all_view_files(scope, page, per_page, search)
true -> load_scoped_files(scope, page, per_page, folder_uuid, search)
end
{:noreply,
socket
|> assign(:uploaded_files, files)
|> assign(:current_page, page)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / per_page))}
end
end
# ──────────────────────────────────────────────────────────────
# Navigation helpers
# ──────────────────────────────────────────────────────────────
defp do_toggle_file(socket, file_uuid) do
selected = socket.assigns.selected_files
selected =
if MapSet.member?(selected, file_uuid),
do: MapSet.delete(selected, file_uuid),
else: MapSet.put(selected, file_uuid)
assign(socket, :selected_files, selected)
end
# Look up the clicked file's enriched map (filename, mime_type, size, urls,
# …) inside the current page's uploaded_files list so the modal can render
# without an extra DB roundtrip.
defp find_uploaded_file(socket, file_uuid) do
Enum.find(socket.assigns.uploaded_files, fn f -> f.file_uuid == file_uuid end)
end
# Advance the modal viewer by one step in the current page's file list.
# Stops at the boundary (no wrap-around) so the user knows they hit the
# edge instead of being silently teleported to the other end.
defp step_viewer(socket, direction) do
current = socket.assigns.viewer_file
list = socket.assigns.uploaded_files
with %{file_uuid: uuid} <- current,
idx when is_integer(idx) <-
Enum.find_index(list, fn f -> f.file_uuid == uuid end),
next_idx <- if(direction == :prev, do: idx - 1, else: idx + 1),
true <- next_idx >= 0 and next_idx < length(list),
%{} = next_file <- Enum.at(list, next_idx) do
open_viewer(socket, next_file)
else
_ -> socket
end
end
# Opens / closes the modal viewer. The per-file content (canvas,
# annotations, composer, sidebar with comments) lives in the
# `MediaCanvasViewer` child LiveComponent embedded in the viewer
# heex — it loads its own annotations on mount based on the file
# we pass through `assigns[:file]`. MediaBrowser only owns the
# modal open/close lifecycle and which file is currently shown.
defp open_viewer(socket, nil), do: assign(socket, :viewer_file, nil)
defp open_viewer(socket, %{file_uuid: _} = file), do: assign(socket, :viewer_file, file)
defp navigate_to_folder(socket, folder_uuid) when folder_uuid in [nil, ""] do
if controlled_mode?(socket) do
send(self(), {__MODULE__, socket.assigns.id, {:navigate, %{folder: nil, q: "", page: 1}}})
{:noreply, socket}
else
scope = scope_folder_id(socket)
per_page = socket.assigns.per_page
{files, total_count} = load_scoped_files(scope, 1, per_page, nil, "")
{:noreply,
socket
|> assign(:file_view, nil)
|> assign(:filter_trash, false)
|> assign(:filter_orphaned, false)
|> assign(:current_folder, nil)
|> assign(:breadcrumbs, [])
|> assign(:folders, Storage.list_folders(nil, scope))
|> assign(:search_query, "")
|> assign(:uploaded_files, files)
|> assign(:current_page, 1)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / per_page))}
end
end
defp navigate_to_folder(socket, folder_uuid) do
if controlled_mode?(socket) do
send(
self(),
{__MODULE__, socket.assigns.id, {:navigate, %{folder: folder_uuid, q: "", page: 1}}}
)
{:noreply, socket}
else
scope = scope_folder_id(socket)
{current_folder, actual_uuid} =
if folder_uuid do
folder = Storage.get_folder(folder_uuid)
if folder && Storage.within_scope?(folder.uuid, scope) do
{folder, folder.uuid}
else
{nil, nil}
end
else
{nil, nil}
end
breadcrumbs = Storage.folder_breadcrumbs(actual_uuid, scope)
folders = Storage.list_folders(actual_uuid, scope)
per_page = socket.assigns.per_page
{files, total_count} = load_scoped_files(scope, 1, per_page, actual_uuid, "")
{:noreply,
socket
|> assign(:file_view, nil)
|> assign(:filter_trash, false)
|> assign(:filter_orphaned, false)
|> assign(:current_folder, current_folder)
|> assign(:breadcrumbs, breadcrumbs)
|> assign(:folders, folders)
|> assign(:search_query, "")
|> assign(:uploaded_files, files)
|> assign(:current_page, 1)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / per_page))
|> auto_expand_breadcrumbs(breadcrumbs)}
end
end
# Refresh `@folders` (the children of the current navigation folder)
# and `@folder_tree` (the sidebar tree). Called from handlers that
# mutate folder rows — move, bulk move/delete — so the UI shows the
# new structure without a manual refresh. File-only mutations skip
# this since they don't change the folder set.
defp reload_folder_lists(socket) do
scope = scope_folder_id(socket)
parent_uuid = current_folder_uuid(socket)
filter_trash = socket.assigns[:filter_trash]
file_view = socket.assigns[:file_view]
folders =
cond do
filter_trash -> Storage.list_trashed_folders(scope)
file_view == "all" -> []
true -> Storage.list_folders(parent_uuid, scope)
end
socket
|> assign(:folders, folders)
|> assign(:folder_tree, Storage.list_folder_tree(scope))
end
# Combined trash count for the sidebar badge — files + folders.
# Used wherever `:trash_count` is assigned so the badge reflects the
# whole trash bucket, not just files.
defp full_trash_count(scope) do
Storage.count_trashed_files(scope) + Storage.count_trashed_folders(scope)
end
defp reload_current_page(socket) do
folder_uuid = current_folder_uuid(socket)
page = socket.assigns.current_page
per_page = socket.assigns.per_page
search = socket.assigns.search_query
scope = scope_folder_id(socket)
file_view = socket.assigns[:file_view]
{files, total_count} =
cond do
socket.assigns[:filter_trash] -> load_trashed_files(scope, page, per_page)
socket.assigns.filter_orphaned -> load_orphaned_files(page, per_page)
file_view == "all" -> load_all_view_files(scope, page, per_page, search)
true -> load_scoped_files(scope, page, per_page, folder_uuid, search)
end
socket
|> assign(:uploaded_files, files)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / per_page))
|> assign(:trash_count, full_trash_count(scope_folder_id(socket)))
end
defp current_folder_uuid(socket) do
socket.assigns.current_folder && socket.assigns.current_folder.uuid
end
defp scope_folder_id(socket), do: socket.assigns[:scope_folder_id]
defp controlled_mode?(socket), do: socket.assigns[:on_navigate] != nil
# ──────────────────────────────────────────────────────────────
# Data loading
# ──────────────────────────────────────────────────────────────
# Both trash and orphan views render through the same list / grid
# markup, so delegate to `enrich_files/1` for the per-file display
# shape (urls, folder_path, etc.). Previously these hand-rolled the
# map and omitted `folder_path`, which the list-view Path column
# reads unconditionally — opening the trash or orphan view in list
# mode crashed with `KeyError`.
defp load_trashed_files(scope, page, per_page) do
offset = (page - 1) * per_page
total_count = Storage.count_trashed_files(scope)
files = Storage.list_trashed_files(scope, limit: per_page, offset: offset)
{enrich_files(files), total_count}
end
defp load_orphaned_files(page, per_page) do
offset = (page - 1) * per_page
total_count = Storage.count_orphaned_files()
files = Storage.find_orphaned_files(limit: per_page, offset: offset)
{enrich_files(files), total_count}
end
# Loads files within scope with optional folder/search filters.
# When scope=nil AND folder_uuid=nil AND search="", passes include_orphaned: true
# to preserve the /admin/media root view behavior of showing only orphan files.
defp load_scoped_files(scope, page, per_page, folder_uuid, search) do
at_real_root = is_nil(scope) and is_nil(folder_uuid) and search in [nil, ""]
opts = [page: page, per_page: per_page]
opts =
if folder_uuid && folder_uuid != "", do: [{:folder_uuid, folder_uuid} | opts], else: opts
opts = if search && search != "", do: [{:search, search} | opts], else: opts
opts = if at_real_root, do: [{:include_orphaned, true} | opts], else: opts
case Storage.list_files_in_scope(scope, opts) do
{:error, :out_of_scope} -> {[], 0}
{files, total_count} -> {enrich_files(files), total_count}
end
end
# Loads ALL files in the system (or within scope subtree when scope is set).
# Used for the view=all flat listing. Does not apply folder or orphan filters.
defp load_all_view_files(scope, page, per_page, search) do
opts = [page: page, per_page: per_page]
opts = if search && search != "", do: [{:search, search} | opts], else: opts
case Storage.list_files_in_scope(scope, opts) do
{:error, :out_of_scope} -> {[], 0}
{files, total_count} -> {enrich_files(files), total_count}
end
end
# Enriches raw Storage.File structs with URLs, folder paths, and display fields.
defp enrich_files([]), do: []
defp enrich_files(files) do
repo = PhoenixKit.Config.get_repo()
file_uuids = Enum.map(files, & &1.uuid)
instances_by_file =
from(fi in FileInstance, where: fi.file_uuid in ^file_uuids)
|> repo.all()
|> Enum.group_by(& &1.file_uuid)
folder_uuids =
files |> Enum.map(& &1.folder_uuid) |> Enum.reject(&is_nil/1) |> Enum.uniq()
folder_paths = Map.new(folder_uuids, fn fuuid -> {fuuid, breadcrumb_path(fuuid)} end)
Enum.map(files, fn file ->
instances = Map.get(instances_by_file, file.uuid, [])
urls = generate_urls_from_instances(instances, file.uuid, file.mime_type)
variant_widths = generate_widths_from_instances(instances)
%{
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,
inserted_at: file.inserted_at,
# Width and height come straight from the File schema columns
# populated by the variant generator on upload. `nil` is
# possible for files uploaded before the variant generator ran
# (or for non-image types); `build_viewer_canvas/2` falls back
# to a 1000x1000 placeholder canvas in that case so the LV
# doesn't crash.
width: file.width,
height: file.height,
folder_path: Map.get(folder_paths, file.folder_uuid),
urls: urls,
variant_widths: variant_widths
}
end)
end
# ──────────────────────────────────────────────────────────────
# Upload processing
# ──────────────────────────────────────────────────────────────
defp process_single_upload(socket, path, entry) do
ext = Path.extname(entry.client_name) |> String.replace_leading(".", "")
mime_type = entry.client_type || MIME.from_path(entry.client_name)
file_type = determine_file_type(mime_type)
current_user = socket.assigns[:phoenix_kit_current_user]
user_uuid = if current_user, do: current_user.uuid, else: nil
{:ok, stat} = Elixir.File.stat(path)
file_size = stat.size
file_hash = Auth.calculate_file_hash(path)
case Storage.store_file_in_buckets(
path,
file_type,
user_uuid,
file_hash,
ext,
entry.client_name
) do
{:ok, file, :duplicate} ->
maybe_set_folder(file, socket)
build_upload_result(file, entry, file_type, mime_type, file_size, true)
{:ok, file} ->
maybe_set_folder(file, socket)
build_upload_result(file, entry, file_type, mime_type, file_size, false)
{:error, reason} ->
Logger.error("MediaBrowser upload error: #{inspect(reason)}")
{:postpone, reason}
end
end
defp build_upload_result(file, entry, file_type, mime_type, file_size, is_duplicate) do
result = %{
file_uuid: file.uuid,
filename: entry.client_name,
file_type: file_type,
mime_type: mime_type,
size: file_size,
status: file.status,
urls: %{}
}
result = if is_duplicate, do: Map.put(result, :duplicate, true), else: result
{:ok, result}
end
defp build_upload_flash_message(uploaded_files) do
error_count = Enum.count(uploaded_files, &match?({:postpone, _}, &1))
successful_uploads = Enum.reject(uploaded_files, &match?({:postpone, _}, &1))
duplicate_count =
Enum.count(successful_uploads, fn
{:ok, %{duplicate: true}} -> true
_ -> false
end)
new_count = length(successful_uploads) - duplicate_count
build_flash_from_counts(error_count, new_count, duplicate_count)
end
defp build_flash_from_counts(error_count, new_count, duplicate_count) do
cond do
all_failed?(error_count, new_count, duplicate_count) ->
build_all_failed_message()
partial_success?(error_count, new_count) ->
build_partial_success_message(new_count, error_count)
only_duplicates?(duplicate_count, new_count) ->
build_only_duplicates_message(duplicate_count)
new_files_only?(new_count, duplicate_count) ->
build_new_files_only_message(new_count)
new_and_duplicates?(new_count, duplicate_count) ->
build_new_and_duplicates_message(new_count, duplicate_count)
true ->
{:info, gettext("Upload processed")}
end
end
defp all_failed?(error_count, new_count, duplicate_count),
do: error_count > 0 && new_count == 0 && duplicate_count == 0
defp partial_success?(error_count, new_count), do: error_count > 0 && new_count > 0
defp only_duplicates?(duplicate_count, new_count), do: duplicate_count > 0 && new_count == 0
defp new_files_only?(new_count, duplicate_count), do: new_count > 0 && duplicate_count == 0
defp new_and_duplicates?(new_count, duplicate_count), do: new_count > 0 && duplicate_count > 0
defp build_all_failed_message do
{:error,
gettext(
"Upload failed: No storage buckets configured. Please configure at least one storage bucket before uploading files."
)}
end
defp build_partial_success_message(new_count, error_count) do
uploaded = ngettext("%{count} file uploaded", "%{count} files uploaded", new_count)
failed = ngettext("%{count} failed", "%{count} failed", error_count)
{:warning,
gettext(
"Partially successful: %{uploaded}, %{failed} due to missing storage buckets.",
uploaded: uploaded,
failed: failed
)}
end
defp build_only_duplicates_message(duplicate_count) do
{:info,
ngettext(
"Already have %{count} duplicate file. No new files were added.",
"Already have %{count} duplicate files. No new files were added.",
duplicate_count
)}
end
defp build_new_files_only_message(new_count) do
{:info,
ngettext(
"Upload successful! %{count} new file processed",
"Upload successful! %{count} new files processed",
new_count
)}
end
defp build_new_and_duplicates_message(new_count, duplicate_count) do
added = ngettext("%{count} new file added", "%{count} new files added", new_count)
duplicates =
ngettext(
"%{count} file was already uploaded",
"%{count} files were already uploaded",
duplicate_count
)
{:info,
gettext("Upload successful! %{added}. %{duplicates}.",
added: added,
duplicates: duplicates
)}
end
defp maybe_set_folder(file, socket) do
scope = scope_folder_id(socket)
folder_uuid = current_folder_uuid(socket) || scope
cond do
is_nil(folder_uuid) ->
:ok
is_nil(scope) or Storage.within_scope?(folder_uuid, scope) ->
# The target folder is verified in-scope above, so passing nil as the
# scope arg to move_file_to_folder/3 is safe — it skips the scope
# re-check against the file's current folder (which is the real root
# for a fresh upload and would fail the gate).
Storage.move_file_to_folder(file.uuid, folder_uuid, nil)
true ->
Logger.warning(
"MediaBrowser: refusing out-of-scope initial placement " <>
"target=#{inspect(folder_uuid)} scope=#{inspect(scope)}"
)
:ok
end
end
# ──────────────────────────────────────────────────────────────
# Style / icon helpers
# ──────────────────────────────────────────────────────────────
defp generate_urls_from_instances(instances, file_uuid, mime_type) do
base =
Enum.reduce(instances, %{}, fn instance, acc ->
url = URLSigner.signed_url(file_uuid, instance.variant_name)
Map.put(acc, instance.variant_name, url)
end)
# For images, surface a DZI manifest URL only when tile generation is
# enabled in storage settings. The manifest itself is generated lazily
# on first request; tiles are generated lazily as OSD asks for them.
# When the setting is off, no `urls["dzi"]` → Tessera falls back to
# the medium / large layers and never asks for tiles, so the
# lazy-generation path is never triggered.
#
# The signed token lives in the URL path (not query string) so
# OpenSeadragon's tile-URL derivation preserves it across the
# manifest → tile fetch. The "dzi" variant name is distinct from
# the storage variants ("original" / "small" / "medium" / "large")
# so a leaked file-serving token can't grant tile access.
if is_binary(mime_type) and String.starts_with?(mime_type, "image/") and
tile_generation_enabled?() do
token = URLSigner.generate_token(file_uuid, "dzi")
Map.put(base, "dzi", Routes.path("/tiles/#{token}/#{file_uuid}.dzi"))
else
base
end
end
defp tile_generation_enabled? do
Settings.get_setting("storage_tile_generation_enabled", "false") == "true"
end
# Builds a parallel map of `%{variant_name => width}` from the same
# FileInstance rows that produce the URLs. Used downstream by
# `tessera_sources/1` to decide which variants to surface as zoom
# layers and where to set the swap thresholds.
defp generate_widths_from_instances(instances) do
Enum.reduce(instances, %{}, fn instance, acc ->
case instance.width do
nil -> acc
w -> Map.put(acc, instance.variant_name, w)
end
end)
end
# NOTE: `tessera_sources/1` + `maybe_append_layer/4` were removed in
# the Fresco 0.5 / Etcher 0.3 migration. Tessera 0.2 was OpenSeadragon-
# backed (Fresco 0.1.x); Fresco 0.5 dropped OSD entirely, so the
# Tessera layer no longer attaches. Restore once a Fresco 0.5-compatible
# Tessera (or replacement) ships and `<Tessera.layer>` is wired back
# into the file-zoom heex.
defp determine_file_type(mime_type) do
cond do
String.starts_with?(mime_type, "image/") -> "image"
String.starts_with?(mime_type, "video/") -> "video"
# PDFs fall under "document" because the File schema's allowlist is
# ["image", "video", "audio", "document", "archive", "other"] — returning
# "pdf" here made every PDF upload fail the changeset validation silently.
mime_type == "application/pdf" -> "document"
true -> "document"
end
end
# Build a "Folder1 / Folder2 / ..." path string from a folder uuid.
# Returns nil at root so callers can render a `/` placeholder. Shared
# by `enrich_files` (per-file folder_path) and `folder_list_path`
# (single per-render path for the folder list view).
defp breadcrumb_path(nil), do: nil
defp breadcrumb_path(uuid) do
case Storage.folder_breadcrumbs(uuid) do
[] -> nil
chain -> Enum.map_join(chain, " / ", & &1.name)
end
end
# Path shown in the list-view folder rows' Path column. All folders
# displayed share the same parent (children of @current_folder, or of
# the scope when at the scoped root), so we compute it once per render
# instead of recomputing per row — the previous `folder_parent_path/1`
# did one breadcrumbs walk per folder, an N+1 over identical work.
defp folder_list_path(nil, scope_id), do: breadcrumb_path(scope_id)
defp folder_list_path(%{uuid: uuid}, _scope_id), do: breadcrumb_path(uuid)
# Finder-style "untitled" / "untitled 1" / "untitled 2" naming for
# quick folder creation. Looks at sibling folders in the same parent
# and picks the first non-conflicting name. Gaps fill before
# extending — if "untitled" and "untitled 5" exist, we create
# "untitled 1".
defp next_untitled_name(parent_uuid, scope) do
base = gettext("untitled")
existing =
parent_uuid
|> Storage.list_folders(scope)
|> Enum.map(& &1.name)
|> MapSet.new()
if MapSet.member?(existing, base) do
n =
Stream.iterate(1, &(&1 + 1))
|> Enum.find(fn n -> not MapSet.member?(existing, "#{base} #{n}") end)
"#{base} #{n}"
else
base
end
end
defp format_file_size(bytes), do: Format.bytes(bytes, base: 1000, decimals: 2)
defp delete_selected_confirm(selected_files, selected_folders, filter_trash) do
file_count = MapSet.size(selected_files)
folder_count = MapSet.size(selected_folders)
cond do
filter_trash and file_count > 0 ->
ngettext(
"Permanently delete %{count} file? This cannot be undone.",
"Permanently delete %{count} files? This cannot be undone.",
file_count
)
folder_count > 0 and file_count > 0 ->
files = ngettext("%{count} file", "%{count} files", file_count)
folders = ngettext("%{count} folder", "%{count} folders", folder_count)
gettext(
"Delete %{files} (to trash) and %{folders}? Folder contents will be moved to parent.",
files: files,
folders: folders
)
folder_count > 0 ->
ngettext(
"Delete %{count} folder? Folder contents will be moved to parent.",
"Delete %{count} folders? Folder contents will be moved to parent.",
folder_count
)
true ->
ngettext("Move %{count} file to trash?", "Move %{count} files to trash?", file_count)
end
end
defp file_icon("image"), do: "hero-photo"
defp file_icon("video"), do: "hero-play-circle"
defp file_icon("pdf"), do: "hero-document-text"
defp file_icon("document"), do: "hero-document"
defp file_icon(_), do: "hero-document-arrow-down"
defp auto_expand_breadcrumbs(socket, breadcrumbs) do
ancestor_uuids = Enum.map(breadcrumbs, & &1.uuid)
expanded = Enum.reduce(ancestor_uuids, socket.assigns.expanded_folders, &MapSet.put(&2, &1))
assign(socket, :expanded_folders, expanded)
end
defp push_tree_state(socket) do
push_event(socket, "save_tree_state", %{
expanded: MapSet.to_list(socket.assigns.expanded_folders),
sidebar_collapsed: socket.assigns.sidebar_collapsed
})
end
end