Packages

phoenix_kit

1.7.103
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.html.heex
Raw

lib/phoenix_kit_web/components/media_browser.html.heex

<%!-- MediaBrowser LiveComponent template.
All phx-* events route to the component via phx-target={@myself} on the root div. --%>
<div id={@id} phx-target={@myself} phx-hook="MediaDragDrop">
<style>
@keyframes rename-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.renaming-preview {
animation: rename-pulse 1.5s ease-in-out infinite;
color: oklch(var(--p));
font-style: italic;
}
/* Compact-mode overrides for the Comments component when embedded
inside the modal viewer's narrow sidebar. The component itself is
designed for full-width post-detail pages; here we shrink the
internal typography, paddings, avatars, and button gaps so the
author/actions row stops truncating in ~360px of space. */
.leaf-comments-compact h2 { font-size: 1rem; line-height: 1.5rem; }
.leaf-comments-compact h3 { font-size: 0.8125rem; line-height: 1.25rem; }
.leaf-comments-compact .text-sm { font-size: 0.75rem; line-height: 1rem; }
.leaf-comments-compact .text-xs { font-size: 0.6875rem; line-height: 0.95rem; }
.leaf-comments-compact .p-3 { padding: 0.5rem; }
.leaf-comments-compact .p-4 { padding: 0.625rem; }
.leaf-comments-compact .gap-2 { gap: 0.25rem; }
.leaf-comments-compact .gap-4 { gap: 0.5rem; }
.leaf-comments-compact .mb-2 { margin-bottom: 0.25rem; }
.leaf-comments-compact .mt-4 { margin-top: 0.5rem; }
.leaf-comments-compact .w-5 { width: 1rem; }
.leaf-comments-compact .h-5 { height: 1rem; }
.leaf-comments-compact .w-4 { width: 0.875rem; }
.leaf-comments-compact .h-4 { height: 0.875rem; }
.leaf-comments-compact .btn-xs { min-height: 1.5rem; height: 1.5rem; padding: 0 0.4rem; font-size: 0.6875rem; }
.leaf-comments-compact .btn-sm { min-height: 1.75rem; height: 1.75rem; font-size: 0.75rem; }
</style>
<%!-- Scope-invalid guard: scope folder was deleted --%>
<%= if @scope_invalid do %>
<div role="alert" class="alert alert-warning mb-4">
<.icon name="hero-exclamation-triangle" class="w-5 h-5 shrink-0" />
<span>
The configured scope folder no longer exists. The media browser is disabled until the scope is reconfigured.
</span>
</div>
<% else %>
<%!-- Upload Section --%>
<%= if @show_upload and assigns[:parent_uploads] do %>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="flex items-center gap-3 mb-4">
<h2 class="card-title text-xl">Upload Media</h2>
<button
phx-click="toggle_upload"
phx-target={@myself}
class="btn btn-sm btn-error gap-2"
>
<.icon name="hero-x-mark" class="w-4 h-4" /> Cancel
</button>
</div>
<%= if @has_buckets do %>
<.file_upload
upload={@parent_uploads.media_files}
label="Upload Media Files"
icon="hero-cloud-arrow-up"
accept_description="All file types supported"
max_size_description={"#{@max_upload_size_mb}MB per file"}
/>
<% else %>
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-6 h-6" />
<div>
<h3 class="font-bold">No Storage Buckets Configured</h3>
<div class="text-sm mt-1">
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/settings/media")}
class="link link-primary font-semibold"
>
Configure Storage Buckets
</.link>
</div>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%!-- Main Layout: Sidebar + Content in a unified card --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-4 flex flex-row gap-0">
<%!-- Folder Sidebar --%>
<div
class="hidden lg:block shrink-0"
style={if !@sidebar_collapsed, do: "width: 240px; max-width: 240px;"}
>
<%= if @sidebar_collapsed do %>
<%!-- Collapsed strip --%>
<div class="sticky top-4 w-10">
<button
phx-click="toggle_sidebar"
phx-target={@myself}
class="btn btn-ghost btn-sm w-full"
title="Show folders"
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
<% else %>
<%!-- Expanded sidebar --%>
<div
class="sticky top-4 border-r border-base-200 pr-3 mr-3 overflow-hidden"
style="width: 240px; max-width: 240px;"
>
<div class="flex items-center justify-between mb-3">
<%= if is_nil(@scope_folder_id) do %>
<h3 class="font-semibold text-sm text-base-content/70 uppercase tracking-wider">
Folders
</h3>
<% else %>
<div></div>
<% end %>
<div class="flex gap-0.5">
<button
phx-click="toggle_new_folder"
phx-target={@myself}
class="btn btn-ghost btn-xs"
title="New folder"
>
<.icon name="hero-folder-plus" class="w-4 h-4" />
</button>
<button
phx-click="toggle_sidebar"
phx-target={@myself}
class="btn btn-ghost btn-xs"
title="Collapse sidebar"
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
</div>
</div>
<%!-- All Files flat view (only when unscoped — admin media page) --%>
<%= if is_nil(@scope_folder_id) do %>
<button
phx-click="navigate_view_all"
phx-target={@myself}
class={[
"flex items-center gap-2 w-full px-2 py-1.5 rounded-lg text-sm transition-colors mb-1 text-left",
if(@file_view == "all",
do: "bg-primary/10 font-semibold text-primary",
else: "hover:bg-base-200"
)
]}
>
<.icon name="hero-rectangle-stack" class="w-4 h-4 shrink-0" /> All Files
</button>
<% end %>
<%!-- Root (navigate to real root folder) --%>
<button
phx-click="navigate_root"
phx-target={@myself}
data-drop-folder="root"
class={[
"flex items-center gap-2 w-full px-2 py-1.5 rounded-lg text-sm transition-colors mb-1 text-left",
if(@current_folder == nil and @file_view != "all",
do: "bg-primary/10 font-semibold text-primary",
else: "hover:bg-base-200"
)
]}
>
<.icon name="hero-inbox" class="w-4 h-4 shrink-0" /> {@scope_folder_name}
</button>
<div class="divider my-1 h-0"></div>
<%!-- Folder Tree --%>
<ul class="space-y-0.5 w-full overflow-hidden">
<%= for node <- @folder_tree do %>
<.folder_tree_node
node={node}
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={0}
myself={@myself}
/>
<% end %>
<%= if @show_new_folder && @current_folder == nil do %>
<li>
<form
phx-submit="create_folder"
phx-target={@myself}
class="flex items-center gap-0.5 px-1 py-1"
>
<span class="w-5"></span>
<span class="text-warning">
<.icon name="hero-folder-plus" class="w-4 h-4 shrink-0" />
</span>
<input
type="text"
name="name"
placeholder="Folder name"
class="input input-bordered input-xs flex-1 min-w-0 ml-1.5"
phx-mounted={JS.focus()}
required
phx-keydown="toggle_new_folder"
phx-target={@myself}
phx-key="Escape"
/>
</form>
</li>
<% end %>
</ul>
<%!-- Trash --%>
<div class="divider my-1 h-0"></div>
<button
phx-click="toggle_trash_filter"
phx-target={@myself}
class={[
"flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm transition-colors w-full",
if(@filter_trash,
do: "bg-error/10 font-semibold text-error",
else: "hover:bg-base-200 text-base-content/60"
)
]}
>
<.icon name="hero-trash" class="w-4 h-4 shrink-0" /> Trash
<%= if @trash_count > 0 do %>
<span class="badge badge-sm badge-error ml-auto">{@trash_count}</span>
<% end %>
</button>
</div>
<% end %>
</div>
<%!-- Main content area --%>
<div class="flex-1 min-w-0 pl-4">
<%!-- Hidden upload input for folder drag-drop (always in DOM when in a folder) --%>
<%= if @has_buckets && !@show_upload && assigns[:parent_uploads] do %>
<form
phx-change="validate"
id="folder-drop-upload-form"
class="hidden"
>
<.live_file_input upload={@parent_uploads.media_files} />
</form>
<% end %>
<div>
<div
class="p-2"
id={if @has_buckets, do: "folder-drop-upload"}
phx-hook={if @has_buckets, do: "FolderDropUpload"}
phx-target={if @has_buckets, do: @myself}
>
<%!-- Inline upload progress for drag-drop (no modal) --%>
<%= if !@show_upload && assigns[:parent_uploads] && length(@parent_uploads.media_files.entries) > 0 do %>
<div class="space-y-2 mb-4">
<%= for entry <- @parent_uploads.media_files.entries do %>
<div class="flex items-center gap-3 p-3 border border-base-300 rounded-lg bg-base-100">
<div class="flex-1">
<p class="font-medium text-sm truncate">{entry.client_name}</p>
<div class="flex gap-2 items-center mt-1">
<progress
value={entry.progress}
max="100"
class="progress progress-primary progress-sm flex-1"
>
{entry.progress}%
</progress>
<span class="text-xs text-base-content/60 min-w-max">
{entry.progress}%
</span>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
<%!-- Breadcrumbs --%>
<%= if @current_folder do %>
<nav class="breadcrumbs text-sm mb-2">
<ul>
<li>
<button
phx-click="navigate_root"
phx-target={@myself}
class="hover:underline text-primary"
>
All Media
</button>
</li>
<%= for folder <- @breadcrumbs do %>
<li>
<button
phx-click="navigate_folder"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
class="hover:underline text-primary"
>
{folder.name}
</button>
</li>
<% end %>
<li>
{if @current_folder, do: @current_folder.name}
</li>
</ul>
</nav>
<% end %>
<%!-- Search Bar (toggleable) --%>
<%= if @show_search or @search_query != "" do %>
<form phx-change="search" phx-target={@myself} phx-submit="search" class="mb-4">
<label class="input input-bordered input-sm w-full flex items-center gap-2">
<.icon name="hero-magnifying-glass" class="w-4 h-4 text-base-content/40" />
<input
type="text"
name="q"
value={@search_query}
placeholder="Search files..."
class="grow bg-transparent border-none outline-none focus:outline-none"
phx-debounce="300"
autocomplete="off"
phx-mounted={JS.focus()}
/>
<%= if @search_query != "" do %>
<button
type="button"
phx-click="clear_search"
phx-target={@myself}
class="btn btn-ghost btn-xs btn-circle"
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
<% end %>
</label>
</form>
<% end %>
<%!-- Header --%>
<div class="flex justify-between items-center mb-4">
<%= if @select_mode do %>
<%!-- Selection Mode Header --%>
<div class="flex items-center gap-2 flex-wrap">
<button
phx-click="toggle_select_mode"
phx-target={@myself}
class="btn btn-sm btn-error gap-2"
>
<.icon name="hero-x-mark" class="w-4 h-4" /> Cancel
</button>
<span class="text-sm font-semibold">
{MapSet.size(@selected_files) + MapSet.size(@selected_folders)} selected
</span>
<button
phx-click="show_move_modal"
phx-target={@myself}
class="btn btn-sm btn-primary gap-1"
>
<.icon name="hero-folder-arrow-down" class="w-4 h-4" /> Move
</button>
<%= if MapSet.size(@selected_files) + MapSet.size(@selected_folders) > 0 do %>
<div class="dropdown dropdown-end">
<label
tabindex="0"
class="btn btn-sm btn-ghost px-2"
title="More actions"
>
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</label>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-44 p-2 shadow-lg border border-base-200"
>
<%= if MapSet.size(@selected_files) > 0 do %>
<li>
<button phx-click="download_selected" phx-target={@myself}>
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download
</button>
</li>
<% end %>
<li>
<button
phx-click="delete_selected"
phx-target={@myself}
data-confirm={
delete_selected_confirm(
@selected_files,
@selected_folders,
@filter_trash
)
}
class="text-error"
>
<.icon name="hero-trash" class="w-4 h-4" />
{if @filter_trash, do: "Delete permanently", else: "Delete"}
</button>
</li>
</ul>
</div>
<% end %>
<button
phx-click="select_all"
phx-target={@myself}
class="btn btn-sm btn-ghost"
>
Select all
</button>
<button
phx-click="deselect_all"
phx-target={@myself}
class="btn btn-sm btn-ghost"
>
Clear
</button>
</div>
<% else %>
<%!-- Normal Header --%>
<div class="flex items-center gap-3 flex-wrap">
<h2 class="card-title text-xl">
<%= cond do %>
<% @filter_trash -> %>
Trash
<% @filter_orphaned -> %>
Orphaned Files
<% @file_view == "all" -> %>
All Files
<% @current_folder -> %>
{@current_folder.name}
<% true -> %>
{@scope_folder_name}
<% end %>
</h2>
<%= if @filter_trash do %>
<%= if @select_mode and MapSet.size(@selected_files) > 0 do %>
<button
phx-click="restore_selected"
phx-target={@myself}
class="btn btn-sm btn-success gap-1"
>
<.icon name="hero-arrow-uturn-left" class="w-4 h-4" /> Restore
</button>
<% end %>
<%= if @total_count > 0 do %>
<button
phx-click="empty_trash"
phx-target={@myself}
data-confirm={"Permanently delete all #{@total_count} trashed files?"}
class="btn btn-sm btn-error gap-1"
>
<.icon name="hero-trash" class="w-4 h-4" /> Empty Trash
</button>
<% end %>
<% end %>
<button
phx-click="toggle_search"
phx-target={@myself}
class={[
"btn btn-sm btn-circle",
if(@show_search or @search_query != "",
do: "btn-primary",
else: "btn-ghost"
)
]}
title="Search"
>
<.icon name="hero-magnifying-glass" class="w-4 h-4" />
</button>
<%= unless @show_upload or @filter_trash do %>
<button
phx-click="toggle_upload"
phx-target={@myself}
class="btn btn-sm btn-primary gap-2"
>
<.icon name="hero-plus" class="w-4 h-4" /> Add Media
</button>
<% end %>
<button
phx-click="toggle_select_mode"
phx-target={@myself}
class="btn btn-sm btn-secondary btn-outline gap-2"
phx-disable-with="Select"
>
<.icon name="hero-check-circle" class="w-4 h-4" /> Select
</button>
<button
phx-click="toggle_new_folder"
phx-target={@myself}
class="btn btn-sm btn-ghost gap-2 lg:hidden"
>
<.icon name="hero-folder-plus" class="w-4 h-4" /> New Folder
</button>
<%!-- Orphan filter is only meaningful at the global root (no scope set) --%>
<%= if is_nil(@scope_folder_id) do %>
<button
phx-click="toggle_orphan_filter"
phx-target={@myself}
class={[
"btn btn-sm gap-2",
if(@filter_orphaned, do: "btn-warning", else: "btn-ghost")
]}
>
<.icon name="hero-funnel" class="w-4 h-4" />
<%= if @filter_orphaned do %>
Showing orphaned ({@orphaned_count})
<% else %>
Find orphaned ({@orphaned_count})
<% end %>
</button>
<%= if @filter_orphaned && @total_count > 0 do %>
<button
phx-click="delete_all_orphaned"
phx-target={@myself}
data-confirm={"Delete all #{@total_count} orphaned files?"}
class="btn btn-sm btn-error gap-2"
>
<.icon name="hero-trash" class="w-4 h-4" /> Delete all orphaned
</button>
<% end %>
<% end %>
</div>
<% end %>
<div class="flex items-center gap-2">
<%= if @total_count > 0 do %>
<div class="join">
<button
phx-click="set_view_mode"
phx-target={@myself}
phx-value-mode="grid"
data-view-mode="grid"
class={[
"btn btn-xs join-item",
if(@view_mode == "grid", do: "btn-active")
]}
title="Grid view"
>
<.icon name="hero-squares-2x2" class="w-3 h-3" />
</button>
<button
phx-click="set_view_mode"
phx-target={@myself}
phx-value-mode="list"
data-view-mode="list"
class={[
"btn btn-xs join-item",
if(@view_mode == "list", do: "btn-active")
]}
title="List view"
>
<.icon name="hero-bars-3-bottom-left" class="w-3 h-3" />
</button>
</div>
<.pagination_info
page={@current_page}
per_page={@per_page}
total_count={@total_count}
/>
<% end %>
</div>
</div>
<%= if @uploaded_files == [] && (@folders == [] or @file_view == "all") do %>
<%!-- Empty state --%>
<div class={[
"rounded-lg p-8 text-center",
if(@current_folder,
do: "border-2 border-dashed border-base-300 bg-base-200/50",
else: "bg-base-200"
)
]}>
<%= cond do %>
<% @filter_trash -> %>
<.icon
name="hero-trash"
class="w-12 h-12 mx-auto text-base-content/30 mb-2"
/>
<p class="text-base-content/70">Trash is empty.</p>
<p class="text-base-content/50 text-sm mt-1">
Deleted files will appear here
</p>
<% @current_folder -> %>
<.icon
name="hero-cloud-arrow-up"
class="w-12 h-12 mx-auto text-base-content/30 mb-2"
/>
<p class="text-base-content/70">This folder is empty.</p>
<p class="text-base-content/50 text-sm mt-1">
Drop files here or click "Add Media" to upload
</p>
<% @file_view == "all" -> %>
<.icon
name="hero-folder-open"
class="w-12 h-12 mx-auto text-base-content/30 mb-2"
/>
<p class="text-base-content/70">No files found.</p>
<% true -> %>
<.icon
name="hero-folder-open"
class="w-12 h-12 mx-auto text-base-content/30 mb-2"
/>
<p class="text-base-content/70">
No media uploaded yet. Upload your first file above!
</p>
<% end %>
</div>
<% else %>
<%= if @view_mode == "grid" do %>
<%!-- Grid View --%>
<div
data-media-grid
class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3"
>
<%!-- New Folder Input (Grid) --%>
<%= if @show_new_folder and @file_view != "all" and not @filter_trash do %>
<div class="flex flex-col items-center justify-center aspect-square bg-base-200 rounded-lg border-2 border-dashed border-primary/30">
<span class="text-warning mb-2">
<.icon name="hero-folder-plus" class="w-10 h-10" />
</span>
<form phx-submit="create_folder" phx-target={@myself} class="w-full px-3">
<input
type="text"
name="name"
placeholder="Folder name"
class="input input-bordered input-xs w-full text-center"
phx-mounted={JS.focus()}
required
phx-keydown="toggle_new_folder"
phx-target={@myself}
phx-key="Escape"
/>
</form>
</div>
<% end %>
<%!-- Folders --%>
<%= for folder <- if(@file_view == "all", do: [], else: @folders) do %>
<div
data-drop-folder={folder.uuid}
class={[
"group relative flex flex-col items-center justify-center aspect-square bg-base-200 rounded-lg cursor-pointer hover:bg-base-300 hover:shadow-lg transition-colors",
@select_mode && MapSet.member?(@selected_folders, folder.uuid) &&
"ring-2 ring-primary"
]}
style={folder_bg_style(folder.color)}
phx-click={if @select_mode, do: "toggle_select_folder"}
phx-target={@myself}
phx-value-folder-uuid={if @select_mode, do: folder.uuid}
>
<%= if @select_mode do %>
<label class="absolute top-1 left-1 z-10 cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={MapSet.member?(@selected_folders, folder.uuid)}
phx-click="toggle_select_folder"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
/>
</label>
<% end %>
<%= if @renaming_folder == folder.uuid && @renaming_source == "content" do %>
<%!-- Inline rename --%>
<span style={folder_icon_style(folder.color)}>
<.icon name="hero-folder" class="w-12 h-12 mb-2" />
</span>
<form
phx-submit="rename_folder"
phx-target={@myself}
phx-change="rename_folder_input"
class="flex items-center gap-1 px-2 w-full"
>
<input type="hidden" name="folder_uuid" value={folder.uuid} />
<input
type="text"
name="name"
value={@renaming_text}
class="input input-bordered input-xs w-full text-center"
phx-mounted={JS.focus()}
required
phx-keydown="cancel_rename_folder"
phx-target={@myself}
phx-key="Escape"
phx-debounce="50"
/>
</form>
<% else %>
<button
phx-click="navigate_folder"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
class="flex flex-col items-center overflow-hidden w-full"
>
<span style={folder_icon_style(folder.color)}>
<.icon name="hero-folder" class="w-12 h-12 mb-1" />
</span>
<p class={[
"text-xs font-semibold text-center px-2 truncate w-full",
@renaming_folder == folder.uuid && @renaming_source != "content" &&
"renaming-preview"
]}>
<%= if @renaming_folder == folder.uuid && @renaming_text != "" do %>
{@renaming_text}
<% else %>
{folder.name}
<% end %>
</p>
</button>
<div class="dropdown dropdown-end absolute top-1 right-1 opacity-0 group-hover:opacity-100">
<label tabindex="0" class="btn btn-ghost btn-xs p-0 min-h-0 h-5 w-5">
<.icon name="hero-ellipsis-vertical" class="w-4 h-4" />
</label>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg z-20 w-36 p-1"
>
<li>
<button
phx-click="start_rename_folder"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
>
<.icon name="hero-pencil" class="w-4 h-4" /> Rename
</button>
</li>
<li>
<div class="px-3 py-1.5">
<span class="text-xs font-semibold text-base-content/50 mb-1 block">
Color
</span>
<div class="flex flex-wrap gap-1">
<%= for color <- PhoenixKit.Modules.Storage.Folder.colors() do %>
<button
phx-click="change_folder_color"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
phx-value-color={color}
class={[
"w-4 h-4 rounded-full border-2 transition-transform hover:scale-125",
if(
folder.color == color ||
(folder.color == nil && color == "default"),
do: "border-base-content scale-110",
else: "border-transparent"
)
]}
style={"background-color: #{folder_color_hex(color) || "oklch(var(--wa))"}"}
title={color}
>
</button>
<% end %>
</div>
</div>
</li>
<li>
<button
phx-click="delete_folder"
phx-target={@myself}
phx-value-id={folder.uuid}
data-confirm={"Delete '#{folder.name}'? Files move to parent."}
class="text-error"
>
<.icon name="hero-trash" class="w-4 h-4" /> Delete
</button>
</li>
</ul>
</div>
<% end %>
</div>
<% end %>
<%!-- Files --%>
<%= for file <- @uploaded_files do %>
<div
data-draggable-file={file.file_uuid}
class={[
"group relative aspect-square bg-base-300 rounded-lg overflow-hidden hover:shadow-lg transition-shadow",
@select_mode && MapSet.member?(@selected_files, file.file_uuid) &&
"ring-2 ring-primary"
]}
>
<%= if @select_mode do %>
<label class="absolute top-1 left-1 z-10 cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={MapSet.member?(@selected_files, file.file_uuid)}
phx-click="toggle_select"
phx-target={@myself}
phx-value-file-uuid={file.file_uuid}
/>
</label>
<% end %>
<div
phx-click="click_file"
phx-target={@myself}
phx-value-file-uuid={file.file_uuid}
class="block w-full h-full cursor-pointer"
>
<.thumbnail_url :let={url} file={file} size={:small}>
<%= if url do %>
<img
src={url}
alt={file.filename}
class="w-full h-full object-cover pointer-events-none group-hover:opacity-75 transition-opacity"
onerror="this.parentElement.querySelector('.image-error-placeholder').style.display='grid';"
/>
<div class="image-error-placeholder hidden absolute inset-0 bg-base-300 grid place-items-center text-base-content/50">
<div class="flex flex-col items-center justify-center">
<.icon name="hero-photo" class="w-12 h-12 mb-2 text-error" />
<p class="text-xs font-semibold">Image Missing</p>
</div>
</div>
<% else %>
<div class="grid place-items-center w-full aspect-square text-base-content/50 group-hover:bg-base-200 transition-colors">
<div class="flex flex-col items-center">
<.icon
name={file_icon(file.file_type)}
class="w-12 h-12 mb-2"
/>
<p class="text-sm font-semibold">
{String.upcase(file.file_type)}
</p>
</div>
</div>
<% end %>
</.thumbnail_url>
<%= if file.file_type == "video" do %>
<div class="absolute top-2 left-2 bg-black/60 text-white p-1.5 rounded pointer-events-none flex items-center justify-center">
<.icon name="hero-play-solid" class="w-4 h-4" />
</div>
<% end %>
<%= if file.mime_type == "application/pdf" do %>
<div class="absolute top-2 left-2 bg-error/80 text-white px-2 py-0.5 rounded text-xs font-bold pointer-events-none">
PDF
</div>
<% end %>
<div class="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded pointer-events-none">
{format_file_size(file.size)}
</div>
</div>
</div>
<% end %>
</div>
<% end %>
<%= if @view_mode == "list" do %>
<%!-- List View --%>
<div data-media-list class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<%= if @select_mode do %>
<th class="w-8"></th>
<% end %>
<th class="w-12"></th>
<th>Name</th>
<th>Path</th>
<th>Type</th>
<th>Size</th>
<th>Date</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody>
<%!-- New Folder Input (List) --%>
<%= if @show_new_folder and @file_view != "all" and not @filter_trash do %>
<tr class="bg-base-200">
<%= if @select_mode do %>
<td></td>
<% end %>
<td>
<div class="w-10 h-10 grid place-items-center">
<span class="text-warning">
<.icon name="hero-folder-plus" class="w-6 h-6" />
</span>
</div>
</td>
<td colspan="5">
<form
phx-submit="create_folder"
phx-target={@myself}
class="flex items-center gap-2"
>
<input
type="text"
name="name"
placeholder="Folder name"
class="input input-bordered input-sm flex-1"
phx-mounted={JS.focus()}
required
phx-keydown="toggle_new_folder"
phx-target={@myself}
phx-key="Escape"
/>
<button type="submit" class="btn btn-sm btn-primary">
<.icon name="hero-check" class="w-4 h-4" />
</button>
<button
type="button"
phx-click="toggle_new_folder"
phx-target={@myself}
class="btn btn-sm btn-ghost"
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
</form>
</td>
<td></td>
</tr>
<% end %>
<%!-- Folders --%>
<%= for folder <- if(@file_view == "all", do: [], else: @folders) do %>
<%= if @renaming_folder == folder.uuid && @renaming_source == "content" do %>
<tr style={
folder_bg_style(folder.color) ||
"background-color: oklch(var(--b2))"
}>
<%= if @select_mode do %>
<td></td>
<% end %>
<td>
<div class="w-10 h-10 grid place-items-center">
<span style={folder_icon_style(folder.color)}>
<.icon name="hero-folder" class="w-6 h-6" />
</span>
</div>
</td>
<td colspan="5">
<form
phx-submit="rename_folder"
phx-target={@myself}
phx-change="rename_folder_input"
class="flex items-center gap-2"
>
<input type="hidden" name="folder_uuid" value={folder.uuid} />
<input
type="text"
name="name"
value={@renaming_text}
class="input input-bordered input-sm flex-1"
phx-mounted={JS.focus()}
required
phx-keydown="cancel_rename_folder"
phx-target={@myself}
phx-key="Escape"
phx-debounce="50"
/>
<button type="submit" class="btn btn-sm btn-ghost">
<.icon name="hero-check" class="w-4 h-4 text-success" />
</button>
<button
type="button"
phx-click="cancel_rename_folder"
phx-target={@myself}
class="btn btn-sm btn-ghost"
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
</form>
</td>
<td></td>
</tr>
<% else %>
<tr
phx-click={
if @select_mode,
do: "toggle_select_folder",
else: "navigate_folder"
}
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
data-drop-folder={folder.uuid}
class={[
"hover cursor-pointer",
@select_mode && MapSet.member?(@selected_folders, folder.uuid) &&
"bg-primary/10"
]}
style={folder_bg_style(folder.color)}
>
<%= if @select_mode do %>
<td>
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-xs"
checked={MapSet.member?(@selected_folders, folder.uuid)}
phx-click="toggle_select_folder"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
/>
</td>
<% end %>
<td>
<div class="w-10 h-10 grid place-items-center">
<span style={folder_icon_style(folder.color)}>
<.icon name="hero-folder" class="w-6 h-6" />
</span>
</div>
</td>
<td colspan="5">
<span class={[
"font-medium text-sm",
@renaming_folder == folder.uuid && @renaming_source != "content" &&
"renaming-preview"
]}>
<%= if @renaming_folder == folder.uuid && @renaming_text != "" do %>
{@renaming_text}
<% else %>
{folder.name}
<% end %>
</span>
</td>
<td></td>
<td>
<div
class="dropdown dropdown-end"
phx-click="noop"
phx-target={@myself}
>
<label tabindex="0" class="btn btn-ghost btn-xs">
<.icon name="hero-ellipsis-vertical" class="w-4 h-4" />
</label>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg z-20 w-36 p-1"
>
<li>
<button
phx-click="start_rename_folder"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
>
<.icon name="hero-pencil" class="w-4 h-4" /> Rename
</button>
</li>
<li>
<div class="px-3 py-1.5">
<span class="text-xs font-semibold text-base-content/50 mb-1 block">
Color
</span>
<div class="flex flex-wrap gap-1">
<%= for color <- PhoenixKit.Modules.Storage.Folder.colors() do %>
<button
phx-click="change_folder_color"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
phx-value-color={color}
class={[
"w-4 h-4 rounded-full border-2 transition-transform hover:scale-125",
if(
folder.color == color ||
(folder.color == nil && color == "default"),
do: "border-base-content scale-110",
else: "border-transparent"
)
]}
style={"background-color: #{folder_color_hex(color) || "oklch(var(--wa))"}"}
title={color}
>
</button>
<% end %>
</div>
</div>
</li>
<li>
<button
phx-click="delete_folder"
phx-target={@myself}
phx-value-id={folder.uuid}
data-confirm={"Delete '#{folder.name}'? Files move to parent."}
class="text-error"
>
<.icon name="hero-trash" class="w-4 h-4" /> Delete
</button>
</li>
</ul>
</div>
</td>
</tr>
<% end %>
<% end %>
<%!-- Files --%>
<%= for file <- @uploaded_files do %>
<tr
data-draggable-file={file.file_uuid}
phx-click="click_file"
phx-target={@myself}
phx-value-file-uuid={file.file_uuid}
class={[
"hover cursor-pointer",
@select_mode &&
MapSet.member?(@selected_files, file.file_uuid) && "bg-primary/10"
]}
>
<%= if @select_mode do %>
<td>
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-xs"
checked={MapSet.member?(@selected_files, file.file_uuid)}
phx-click="toggle_select"
phx-target={@myself}
phx-value-file-uuid={file.file_uuid}
/>
</td>
<% end %>
<td>
<div class="w-10 h-10 rounded overflow-hidden bg-base-300 shrink-0">
<.thumbnail_url :let={url} file={file} size={:small}>
<%= if url do %>
<img
src={url}
class="w-full h-full object-cover"
alt={file.filename}
/>
<% else %>
<div class="grid place-items-center w-full h-full text-base-content/40">
<.icon name={file_icon(file.file_type)} class="w-5 h-5" />
</div>
<% end %>
</.thumbnail_url>
</div>
</td>
<td>
<span class="font-medium text-sm">{file.filename}</span>
</td>
<td>
<%= if file.folder_path do %>
<span class="text-xs text-base-content/50">
{file.folder_path}
</span>
<% else %>
<span class="text-xs text-base-content/30">/</span>
<% end %>
</td>
<td>
<span class="badge badge-ghost badge-sm h-auto">
{String.upcase(file.file_type)}
</span>
</td>
<td class="text-sm text-base-content/70">
{format_file_size(file.size)}
</td>
<td class="text-sm text-base-content/70">
{Calendar.strftime(file.inserted_at, "%b %d, %Y")}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% end %>
<%!-- Pagination (event-based; URL sync deferred to Task 3) --%>
<%= if @total_count > @per_page do %>
<div class="flex justify-center items-center gap-2 mt-4">
<%= if @current_page > 1 do %>
<button
phx-click="set_page"
phx-target={@myself}
phx-value-page={@current_page - 1}
class="btn btn-sm btn-ghost gap-1"
>
<.icon name="hero-chevron-left" class="w-4 h-4" /> Prev
</button>
<% end %>
<span class="text-sm text-base-content/70">
{@current_page} / {@total_pages}
</span>
<%= if @current_page < @total_pages do %>
<button
phx-click="set_page"
phx-target={@myself}
phx-value-page={@current_page + 1}
class="btn btn-sm btn-ghost gap-1"
>
Next <.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
</div>
</div>
<%!-- Move to Folder Modal --%>
<%= if @show_move_modal do %>
<div class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Move {MapSet.size(@selected_files)} file(s) to folder
</h3>
<ul class="menu bg-base-200 rounded-lg max-h-64 overflow-y-auto">
<li>
<button
phx-click="move_selected_to_folder"
phx-target={@myself}
phx-value-folder_uuid=""
class={if @current_folder == nil, do: "active", else: ""}
>
<.icon name="hero-home" class="w-4 h-4" /> {@scope_folder_name}
</button>
</li>
<%= for node <- @folder_tree do %>
<.move_folder_option node={node} depth={0} myself={@myself} />
<% end %>
</ul>
<div class="modal-action">
<button phx-click="close_move_modal" phx-target={@myself} class="btn btn-ghost">
Cancel
</button>
</div>
</div>
<div class="modal-backdrop" phx-click="close_move_modal" phx-target={@myself}></div>
</div>
<% end %>
<% end %>
<%!-- ── Read-only modal viewer ──────────────────────────────────────────── --%>
<%!-- Activated by passing `viewer={true}` to the component. Clicking a file --%>
<%!-- (outside select_mode and admin) sets viewer_file and shows the preview --%>
<%!-- + metadata + Download. Closes via X / Esc / backdrop click. --%>
<%= if assigns[:viewer_file] do %>
<% f = @viewer_file %>
<% viewer_idx = Enum.find_index(@uploaded_files, &(&1.file_uuid == f.file_uuid)) %>
<% has_prev = is_integer(viewer_idx) and viewer_idx > 0 %>
<% has_next = is_integer(viewer_idx) and viewer_idx < length(@uploaded_files) - 1 %>
<div
class="modal modal-open"
phx-window-keydown="viewer_keydown"
phx-target={@myself}
>
<div class="modal-box w-11/12 max-w-7xl p-0 overflow-hidden">
<div class="flex flex-col lg:flex-row lg:h-[80vh]">
<%!-- Viewer (image / video / pdf / icon) --%>
<div class="flex-[7] relative flex items-center justify-center bg-base-200 overflow-hidden p-2 min-h-[40vh] lg:min-h-0">
<%= if has_prev do %>
<button
type="button"
phx-click="step_viewer"
phx-value-dir="prev"
phx-target={@myself}
class="btn btn-circle btn-ghost absolute left-2 top-1/2 -translate-y-1/2 z-10 bg-base-100/80 hover:bg-base-100 shadow-md"
title="Previous (←)"
>
<.icon name="hero-chevron-left" class="w-5 h-5" />
</button>
<% end %>
<%= if has_next do %>
<button
type="button"
phx-click="step_viewer"
phx-value-dir="next"
phx-target={@myself}
class="btn btn-circle btn-ghost absolute right-2 top-1/2 -translate-y-1/2 z-10 bg-base-100/80 hover:bg-base-100 shadow-md"
title="Next (→)"
>
<.icon name="hero-chevron-right" class="w-5 h-5" />
</button>
<% end %>
<%= cond do %>
<% is_binary(f.mime_type) and String.starts_with?(f.mime_type, "image/") -> %>
<img
src={f.urls["original"] || f.urls["medium"]}
alt={f.filename}
class="max-w-full max-h-full object-contain rounded"
/>
<% is_binary(f.mime_type) and String.starts_with?(f.mime_type, "video/") -> %>
<video
src={f.urls["original"]}
controls
class="max-w-full max-h-full rounded"
preload="metadata"
>
Your browser does not support the video tag.
</video>
<% f.mime_type == "application/pdf" -> %>
<iframe
src={f.urls["original"]}
sandbox="allow-same-origin"
class="w-full h-full rounded border-0"
title={f.filename}
>
</iframe>
<% true -> %>
<div class="flex flex-col items-center justify-center text-base-content/50 py-12">
<.icon name={file_icon(f.file_type)} class="w-32 h-32 mb-4" />
<p class="text-2xl font-semibold">{String.upcase(f.file_type || "FILE")}</p>
<p class="text-sm text-base-content/70 mt-2">{f.mime_type}</p>
</div>
<% end %>
</div>
<%!-- Sidebar --%>
<div class="flex-[3] flex flex-col gap-3 p-4 lg:min-w-[280px] lg:overflow-y-auto bg-base-100">
<div class="flex items-start justify-between gap-2">
<h2 class="text-base font-bold break-all leading-tight min-w-0">
{f.filename}
</h2>
<button
type="button"
phx-click="close_viewer"
phx-target={@myself}
class="btn btn-ghost btn-sm btn-circle shrink-0"
title="Close"
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
</div>
<%= if f.urls["original"] do %>
<a
href={f.urls["original"]}
download={f.filename}
target="_blank"
rel="noopener"
class="btn btn-primary btn-sm gap-2 self-start"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download
</a>
<% end %>
<div class="text-sm space-y-2 mt-1">
<div>
<span class="text-base-content/60">Type:</span>
<span class="ml-1 badge badge-ghost">
{String.upcase(f.file_type || "FILE")}
</span>
</div>
<div class="break-all">
<span class="text-base-content/60">MIME:</span>
<span class="ml-1 font-mono text-xs">{f.mime_type}</span>
</div>
<div>
<span class="text-base-content/60">Size:</span>
<span class="ml-1">{format_file_size(f.size)}</span>
</div>
<div>
<span class="text-base-content/60">Uploaded:</span>
<span class="ml-1">
{Calendar.strftime(f.inserted_at, "%b %d, %Y at %H:%M")}
</span>
</div>
</div>
<%!-- Comments section — gated on the Comments module being --%>
<%!-- installed AND enabled, plus a logged-in user. Keyed by --%>
<%!-- file_uuid so prev/next remounts the component cleanly. --%>
<%!-- --%>
<%!-- The wrapper applies a compact-mode override: shrinks the --%>
<%!-- comments component's typography, paddings, and avatars --%>
<%!-- via descendant selectors so it fits inside the sidebar --%>
<%!-- without truncation. The component itself isn't modified. --%>
<%= if comments_enabled?() and assigns[:phoenix_kit_current_user] do %>
<div class="border-t border-base-300 pt-3 mt-3 leaf-comments-compact">
<.live_component
module={PhoenixKitComments.Web.CommentsComponent}
id={"media-comments-" <> f.file_uuid}
resource_type="file"
resource_uuid={f.file_uuid}
current_user={@phoenix_kit_current_user}
show_likes={true}
title="Comments"
/>
</div>
<% end %>
</div>
</div>
</div>
<div class="modal-backdrop" phx-click="close_viewer" phx-target={@myself}></div>
</div>
<% end %>
</div>