Packages

phoenix_kit

1.7.104
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
# PhoenixKitComments is an optional sibling package — silence undefined
# warnings for parent apps that don't install it. The comments_enabled?/0
# helper guards every actual call at runtime with Code.ensure_loaded?/1.
@compile {:no_warn_undefined, [PhoenixKitComments, PhoenixKitComments.Web.CommentsComponent]}
@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 toggle selection
instead, so the component behaves as a picker when embedded outside the
admin UI.
- `viewer` — when `true`, clicks open a read-only modal showing the
clicked file (image / video / PDF / icon) with its metadata and a
Download button. Standard close behaviour (X button, Esc, click on
backdrop). `admin` and `select_mode` both win over `viewer` if also
set, so a caller can opt into modal viewing without losing the
selection picker for users who explicitly enable select mode.
"""
use PhoenixKitWeb, :live_component
require Logger
import Ecto.Query
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.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, 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, Storage.count_trashed_files(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, "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, Storage.count_trashed_files(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(:show_new_folder, false)
|> 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 :current_folder, :any, required: true
attr :expanded_folders, :any, required: true
attr :renaming_folder, :any, required: true
attr :renaming_text, :string, default: ""
attr :show_new_folder, :boolean, default: false
attr :renaming_source, :any, required: true
attr :depth, :integer, default: 0
attr :myself, :any, required: true
def folder_tree_node(assigns) do
assigns =
assign(
assigns,
:is_active,
assigns.current_folder && assigns.current_folder.uuid == assigns.node.folder.uuid
)
assigns =
assign(
assigns,
:is_expanded,
MapSet.member?(assigns.expanded_folders, assigns.node.folder.uuid)
)
assigns = assign(assigns, :has_children, assigns.node.children != [])
assigns =
assign(
assigns,
:is_renaming,
assigns.renaming_folder == assigns.node.folder.uuid &&
assigns.renaming_source == "sidebar"
)
~H"""
<li class="overflow-hidden">
<div
class={[
"flex items-center gap-0.5 rounded-lg px-1 py-1 hover:bg-base-200 transition-colors group overflow-hidden min-w-0",
@is_active && "font-semibold"
]}
style={
if @is_active,
do: "background-color: #{folder_color_hex(@node.folder.color) || "oklch(var(--p))"}25"
}
>
<%!-- Chevron (expand/collapse) --%>
<%= if @has_children do %>
<button
phx-click="toggle_folder_expand"
phx-target={@myself}
phx-value-folder-uuid={@node.folder.uuid}
class="btn btn-ghost btn-xs p-0 min-h-0 h-5 w-5"
>
<.icon
name={if @is_expanded, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"}
class="w-4 h-4 text-base-content/40"
/>
</button>
<% else %>
<span class="w-5"></span>
<% end %>
<%= if @is_renaming do %>
<%!-- Inline rename form --%>
<form
phx-submit="rename_folder"
phx-change="rename_folder_input"
phx-target={@myself}
class="flex items-center gap-1.5 flex-1 min-w-0"
>
<input type="hidden" name="folder_uuid" value={@node.folder.uuid} />
<span style={folder_icon_style(@node.folder.color)}>
<.icon name="hero-folder" class="w-4 h-4 shrink-0" />
</span>
<input
type="text"
name="name"
value={@renaming_text}
class="bg-transparent border-none outline-none text-sm flex-1 min-w-0 p-0 h-auto focus:outline-none focus:ring-0"
phx-mounted={JS.focus()}
required
phx-keydown="cancel_rename_folder"
phx-target={@myself}
phx-key="Escape"
phx-debounce="50"
/>
</form>
<% else %>
<%!-- Folder button (uncontrolled: phx-click instead of .link navigate) --%>
<button
phx-click="navigate_folder"
phx-target={@myself}
phx-value-folder-uuid={@node.folder.uuid}
data-drop-folder={@node.folder.uuid}
class="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden text-sm text-left"
>
<span style={folder_icon_style(@node.folder.color, @is_active)}>
<.icon
name={if @is_expanded, do: "hero-folder-open", else: "hero-folder"}
class="w-4 h-4 shrink-0"
/>
</span>
<span
class={[
"truncate block min-w-0",
@renaming_folder == @node.folder.uuid && !@is_renaming && "renaming-preview"
]}
title={@node.folder.name}
>
<%= if @renaming_folder == @node.folder.uuid && @renaming_text != "" do %>
{@renaming_text}
<% else %>
{@node.folder.name}
<% end %>
</span>
</button>
<%!-- Rename button (visible on hover) --%>
<button
phx-click="start_rename_folder"
phx-target={@myself}
phx-value-folder-uuid={@node.folder.uuid}
phx-value-source="sidebar"
class="btn btn-ghost btn-xs p-0 min-h-0 h-5 w-5 opacity-0 group-hover:opacity-100"
title="Rename"
>
<.icon name="hero-pencil" class="w-3 h-3 text-base-content/40" />
</button>
<% end %>
</div>
<%!-- Children (expanded or active with new folder form) --%>
<%= if (@has_children && @is_expanded) || (@is_active && @show_new_folder) do %>
<ul
class="ml-3 border-l-2 pl-1 overflow-hidden"
style={"border-color: #{folder_color_hex(@node.folder.color) || "oklch(var(--bc) / 0.15)"}"}
>
<%= for child <- @node.children do %>
<.folder_tree_node
node={child}
current_folder={@current_folder}
expanded_folders={@expanded_folders}
renaming_folder={@renaming_folder}
renaming_source={@renaming_source}
renaming_text={@renaming_text}
show_new_folder={@show_new_folder}
depth={@depth + 1}
myself={@myself}
/>
<% end %>
<%= if @is_active && @show_new_folder do %>
<li>
<form
phx-submit="create_folder"
phx-target={@myself}
class="flex items-center gap-0.5 rounded-lg px-1 py-1"
>
<span class="w-5"></span>
<span class="text-warning">
<.icon name="hero-folder-plus" class="w-4 h-4" />
</span>
<input
type="text"
name="name"
placeholder="Folder name"
class="input input-bordered input-xs flex-1 min-w-0"
phx-mounted={JS.focus()}
required
phx-keydown="toggle_new_folder"
phx-target={@myself}
phx-key="Escape"
/>
</form>
</li>
<% end %>
</ul>
<% end %>
</li>
"""
end
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
# ──────────────────────────────────────────────────────────────
def handle_event("toggle_new_folder", _params, socket) do
{:noreply, assign(socket, :show_new_folder, !socket.assigns.show_new_folder)}
end
def handle_event("create_folder", %{"name" => name}, socket) do
folder_uuid = current_folder_uuid(socket)
user = socket.assigns[:phoenix_kit_current_user]
scope = scope_folder_id(socket)
case Storage.create_folder(
%{name: name, parent_uuid: folder_uuid, user_uuid: user && user.uuid},
scope
) do
{:ok, _folder} ->
{:noreply,
socket
|> assign(:show_new_folder, false)
|> assign(:folders, Storage.list_folders(folder_uuid, scope))
|> assign(:folder_tree, Storage.list_folder_tree(scope))
|> put_flash(:info, "Folder created")}
{:error, :out_of_scope} ->
{:noreply, put_flash(socket, :error, "Cannot create folder outside the allowed scope")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to create folder")}
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
case Storage.delete_folder(folder, scope) do
{:error, :out_of_scope} ->
{:noreply, put_flash(socket, :error, "Cannot delete folder outside the allowed scope")}
_ ->
parent_uuid = current_folder_uuid(socket)
{:noreply,
socket
|> assign(:folders, Storage.list_folders(parent_uuid, scope))
|> assign(:folder_tree, Storage.list_folder_tree(scope))
|> put_flash(:info, "Folder deleted")}
end
else
{:noreply, put_flash(socket, :error, "Folder not found")}
end
end
def handle_event(
"move_file_to_folder",
%{"file_uuid" => file_uuid, "folder_uuid" => folder_uuid},
socket
) do
target = if folder_uuid == "", do: nil, else: folder_uuid
scope = scope_folder_id(socket)
case Storage.move_file_to_folder(file_uuid, target, scope) do
{:ok, _} ->
{:noreply, socket |> put_flash(:info, "File moved") |> reload_current_page()}
{:error, :out_of_scope} ->
{:noreply, put_flash(socket, :error, "Cannot move file outside the allowed scope")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to move file")}
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, "Cannot rename folder outside the allowed scope")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "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, "Cannot change folder outside the allowed scope")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "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.
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}"))}
# Caller opted into the modal viewer → stash the clicked file in
# viewer_file so the modal at the bottom of the template renders.
socket.assigns.viewer ->
{:noreply, assign(socket, :viewer_file, find_uploaded_file(socket, file_uuid))}
# Default picker behaviour: enter selection mode and toggle this file in.
true ->
{:noreply, socket |> do_toggle_file(file_uuid) |> assign(:select_mode, true)}
end
end
def handle_event("close_viewer", _params, socket) do
{:noreply, assign(socket, :viewer_file, 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, assign(socket, :viewer_file, 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
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
{:noreply, assign(socket, :show_move_modal, false)}
end
def handle_event("move_selected_to_folder", %{"folder_uuid" => folder_uuid}, socket) do
target = if folder_uuid == "", do: nil, else: folder_uuid
scope = scope_folder_id(socket)
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, "#{file_count + folder_count} item(s) moved")
|> 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 are always permanently deleted (no trash for folders)
Enum.each(socket.assigns.selected_folders, fn folder_uuid ->
folder = Storage.get_folder(folder_uuid)
if folder, do: Storage.delete_folder(folder, scope)
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_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
def handle_event("toggle_trash_filter", _params, socket) do
filter_trash = !socket.assigns.filter_trash
{files, total_count} =
if filter_trash do
load_trashed_files(scope_folder_id(socket), 1, socket.assigns.per_page)
else
scope = scope_folder_id(socket)
folder_uuid = current_folder_uuid(socket)
load_scoped_files(scope, 1, socket.assigns.per_page, folder_uuid, "")
end
{:noreply,
socket
|> assign(:filter_trash, filter_trash)
|> assign(:filter_orphaned, false)
|> assign(:folders, if(filter_trash, do: [], else: socket.assigns.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, Storage.count_trashed_files(scope_folder_id(socket)))}
end
def handle_event("restore_selected", _params, socket) do
scope = scope_folder_id(socket)
repo = PhoenixKit.Config.get_repo()
restored =
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)
{:noreply,
socket
|> assign(:select_mode, false)
|> assign(:selected_files, MapSet.new())
|> put_flash(:info, "#{restored} file(s) restored")
|> 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, "#{count} file(s) permanently deleted")
|> 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, "#{length(orphan_uuids)} orphaned files queued for deletion")}
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
assign(socket, :viewer_file, next_file)
else
_ -> socket
end
end
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
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, Storage.count_trashed_files(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
# ──────────────────────────────────────────────────────────────
defp load_trashed_files(scope, page, per_page) do
repo = PhoenixKit.Config.get_repo()
offset = (page - 1) * per_page
total_count = Storage.count_trashed_files(scope)
files = Storage.list_trashed_files(scope, limit: per_page, offset: offset)
file_uuids = Enum.map(files, & &1.uuid)
instances_by_file =
if file_uuids != [] do
from(fi in FileInstance, where: fi.file_uuid in ^file_uuids)
|> repo.all()
|> Enum.group_by(& &1.file_uuid)
else
%{}
end
existing_files =
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",
file_type: file.file_type,
mime_type: file.mime_type,
size: file.size || 0,
status: file.status,
inserted_at: file.inserted_at,
trashed_at: file.trashed_at,
urls: urls
}
end)
{existing_files, total_count}
end
defp load_orphaned_files(page, per_page) do
repo = PhoenixKit.Config.get_repo()
offset = (page - 1) * per_page
total_count = Storage.count_orphaned_files()
files = Storage.find_orphaned_files(limit: per_page, offset: offset)
file_uuids = Enum.map(files, & &1.uuid)
instances_by_file =
if file_uuids != [] do
from(fi in FileInstance, where: fi.file_uuid in ^file_uuids)
|> repo.all()
|> Enum.group_by(& &1.file_uuid)
else
%{}
end
existing_files =
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,
inserted_at: file.inserted_at,
urls: urls
}
end)
{existing_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 ->
path = Storage.folder_breadcrumbs(fuuid) |> Enum.map_join(" / ", & &1.name)
{fuuid, path}
end)
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,
inserted_at: file.inserted_at,
folder_path: Map.get(folder_paths, file.folder_uuid),
urls: urls
}
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, "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,
"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
{:warning,
"Partially successful: #{new_count} file(s) uploaded, #{error_count} failed due to missing storage buckets."}
end
defp build_only_duplicates_message(duplicate_count) do
{:info, "Already have #{duplicate_count} duplicate file(s). No new files were added."}
end
defp build_new_files_only_message(new_count) do
{:info, "Upload successful! #{new_count} new file(s) processed"}
end
defp build_new_and_duplicates_message(new_count, duplicate_count) do
{:info,
"Upload successful! #{new_count} new file(s) added. #{duplicate_count} file(s) were already uploaded."}
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) 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 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
defp format_file_size(bytes) when is_integer(bytes) do
cond do
bytes >= 1_000_000_000 -> "#{Float.round(bytes / 1_000_000_000, 2)} GB"
bytes >= 1_000_000 -> "#{Float.round(bytes / 1_000_000, 2)} MB"
bytes >= 1_000 -> "#{Float.round(bytes / 1_000, 2)} KB"
true -> "#{bytes} B"
end
end
defp folder_bg_style(color) do
case folder_color_hex(color) do
nil -> nil
hex -> "background-color: #{hex}15"
end
end
defp folder_icon_style(color, _active? \\ false) do
case folder_color_hex(color) do
nil -> "color: oklch(var(--wa))"
hex -> "color: #{hex}"
end
end
defp folder_color_hex("red"), do: "#ef4444"
defp folder_color_hex("orange"), do: "#f97316"
defp folder_color_hex("amber"), do: "#f59e0b"
defp folder_color_hex("yellow"), do: "#eab308"
defp folder_color_hex("lime"), do: "#84cc16"
defp folder_color_hex("green"), do: "#22c55e"
defp folder_color_hex("emerald"), do: "#10b981"
defp folder_color_hex("teal"), do: "#14b8a6"
defp folder_color_hex("cyan"), do: "#06b6d4"
defp folder_color_hex("sky"), do: "#0ea5e9"
defp folder_color_hex("blue"), do: "#3b82f6"
defp folder_color_hex("violet"), do: "#8b5cf6"
defp folder_color_hex("purple"), do: "#a855f7"
defp folder_color_hex("fuchsia"), do: "#d946ef"
defp folder_color_hex("pink"), do: "#ec4899"
defp folder_color_hex("rose"), do: "#f43f5e"
defp folder_color_hex(_), do: nil
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 ->
"Permanently delete #{file_count} file(s)? This cannot be undone."
folder_count > 0 and file_count > 0 ->
"Delete #{file_count} file(s) (to trash) and #{folder_count} folder(s)? Folder contents will be moved to parent."
folder_count > 0 ->
"Delete #{folder_count} folder(s)? Folder contents will be moved to parent."
true ->
"Move #{file_count} file(s) to trash?"
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"
# True only when PhoenixKitComments is in the dep tree AND its admin toggle
# is on. Anything else (module missing, settings table missing, raise from
# enabled?/0) falls through to false so the modal still renders without it.
#
# The @dialyzer attribute silences the cross-package static call —
# phoenix_kit_comments is optional and not a transitive dep of phoenix_kit
# itself, so dialyzer can't see PhoenixKitComments.enabled?/0. The
# `Code.ensure_loaded?/1` guard above handles the actual runtime safety.
@dialyzer {:nowarn_function, comments_enabled?: 0}
defp comments_enabled? do
Code.ensure_loaded?(PhoenixKitComments) and PhoenixKitComments.enabled?()
rescue
_ -> false
end
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