Packages

phoenix_kit

1.7.114
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>
{gettext(
"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">{gettext("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" /> {gettext("Cancel")}
</button>
</div>
<%= if @has_buckets do %>
<.file_upload
upload={@parent_uploads.media_files}
label={gettext("Upload Media Files")}
icon="hero-cloud-arrow-up"
accept_description={gettext("All file types supported")}
max_size_description={gettext("%{mb}MB per file", mb: @max_upload_size_mb)}
/>
<% else %>
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-6 h-6" />
<div>
<h3 class="font-bold">{gettext("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"
>
{gettext("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 --%>
<.folder_explorer
id="media-browser-sidebar"
myself={@myself}
folder_tree={@folder_tree}
current_folder={@current_folder}
expanded_folders={@expanded_folders}
scope_folder_id={@scope_folder_id}
scope_folder_name={@scope_folder_name}
renaming_folder={@renaming_folder}
renaming_source={@renaming_source}
renaming_text={@renaming_text}
filter_trash={@filter_trash}
file_view={@file_view}
sidebar_collapsed={@sidebar_collapsed}
trash_count={@trash_count}
/>
<%!-- 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. `@breadcrumbs` is the full ancestor chain INCLUDING
the current folder (with the scope folder dropped when scoped),
so we iterate `Enum.drop(@breadcrumbs, -1)` for the clickable
middle and render the current folder once at the end. When
scoped, the leading "root" button shows the scope folder's
name (e.g. "banana") instead of "All Media".
--%>
<%= 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"
>
{if @scope_folder_id,
do: @scope_folder_name,
else: gettext("All Media")}
</button>
</li>
<%= for folder <- Enum.drop(@breadcrumbs, -1) 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>
{@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={gettext("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" /> {gettext("Cancel")}
</button>
<span class="text-sm font-semibold">
{ngettext(
"%{count} selected",
"%{count} selected",
MapSet.size(@selected_files) + MapSet.size(@selected_folders)
)}
</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" /> {gettext("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={gettext("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" /> {gettext(
"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: gettext("Delete permanently"),
else: gettext("Delete")}
</button>
</li>
</ul>
</div>
<% end %>
<button
phx-click="select_all"
phx-target={@myself}
class="btn btn-sm btn-ghost"
>
{gettext("Select all")}
</button>
<button
phx-click="deselect_all"
phx-target={@myself}
class="btn btn-sm btn-ghost"
>
{gettext("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 -> %>
{gettext("Trash")}
<% @filter_orphaned -> %>
{gettext("Orphaned Files")}
<% @file_view == "all" -> %>
{gettext("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" /> {gettext(
"Restore"
)}
</button>
<% end %>
<%= if @total_count > 0 do %>
<button
phx-click="empty_trash"
phx-target={@myself}
data-confirm={
ngettext(
"Permanently delete %{count} trashed file?",
"Permanently delete all %{count} trashed files?",
@total_count
)
}
class="btn btn-sm btn-error gap-1"
>
<.icon name="hero-trash" class="w-4 h-4" /> {gettext("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={gettext("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" /> {gettext("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={gettext("Select")}
>
<.icon name="hero-check-circle" class="w-4 h-4" /> {gettext("Select")}
</button>
<button
phx-click="create_untitled_folder"
phx-target={@myself}
class="btn btn-sm btn-ghost gap-2 lg:hidden"
>
<.icon name="hero-folder-plus" class="w-4 h-4" /> {gettext("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 %>
{gettext("Showing orphaned (%{count})", count: @orphaned_count)}
<% else %>
{gettext("Find orphaned (%{count})", count: @orphaned_count)}
<% end %>
</button>
<%= if @filter_orphaned && @total_count > 0 do %>
<button
phx-click="delete_all_orphaned"
phx-target={@myself}
data-confirm={
ngettext(
"Delete %{count} orphaned file?",
"Delete all %{count} orphaned files?",
@total_count
)
}
class="btn btn-sm btn-error gap-2"
>
<.icon name="hero-trash" class="w-4 h-4" /> {gettext(
"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={gettext("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={gettext("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>
<%!--
Main-area drop target. Dropping a folder or file onto the
body wraps the move into `@current_folder` (or the scope
root when at the top of a scoped browser). Outline-only
via `data-drop-no-bg` — a 10% primary tint across the
whole content area is overwhelming compared to the
folder cards.
Omitted in trash / all-files views: the `if` produces
`nil` and `data-drop-folder={nil}` strips the attribute,
so the wrapper isn't a drop target there.
--%>
<div
class="rounded-lg"
data-drop-folder={
if not @filter_trash and @file_view != "all" do
if @current_folder, do: @current_folder.uuid, else: "root"
end
}
data-drop-no-bg
>
<%= 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">{gettext("Trash is empty.")}</p>
<p class="text-base-content/50 text-sm mt-1">
{gettext("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">{gettext("This folder is empty.")}</p>
<p class="text-base-content/50 text-sm mt-1">
{gettext("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">{gettext("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">
{gettext("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"
>
<%!-- Folders --%>
<%= for folder <- if(@file_view == "all", do: [], else: @folders) do %>
<div
data-drop-folder={folder.uuid}
data-draggable-folder={folder.uuid}
data-selected={
if @select_mode && MapSet.member?(@selected_folders, folder.uuid),
do: "true"
}
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-20 h-20 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-20 h-20 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>
<.table_row_menu
id={"folder-kebab-grid-#{folder.uuid}"}
class="!absolute top-1 right-1 opacity-0 group-hover:opacity-100"
>
<.table_row_menu_button
phx-click="start_rename_folder"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
icon="hero-pencil"
label={gettext("Rename")}
/>
<li role="none">
<div class="px-3 py-1.5">
<span class="text-xs font-semibold text-base-content/50 mb-1 block">
{gettext("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>
<.table_row_menu_button
phx-click="prepare_move_folder"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
icon="hero-folder-arrow-down"
label={gettext("Move")}
/>
<.table_row_menu_button
phx-click="delete_folder"
phx-target={@myself}
phx-value-id={folder.uuid}
data-confirm={
gettext("Delete '%{name}'? Files move to parent.",
name: folder.name
)
}
icon="hero-trash"
label={gettext("Delete")}
variant="error"
/>
</.table_row_menu>
<% end %>
</div>
<% end %>
<%!-- Files --%>
<%= for file <- @uploaded_files do %>
<div
data-draggable-file={file.file_uuid}
data-selected={
if @select_mode && MapSet.member?(@selected_files, file.file_uuid),
do: "true"
}
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">
{gettext("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>
<%!--
Per-file kebab menu. Sibling of the click target so
its buttons don't fire `click_file`. Mirrors the
folder kebab pattern: hidden by default, fades in
on hover. Always shows Delete; shows Download only
when an "original" URL is present.
--%>
<%= unless @select_mode do %>
<.table_row_menu
id={"file-kebab-grid-#{file.file_uuid}"}
class="!absolute top-1 right-1 opacity-0 group-hover:opacity-100"
trigger_class="!bg-black/40 hover:!bg-black/60 !text-white !border-0"
>
<.table_row_menu_button
:if={
Map.get(file.urls || %{}, "original") ||
Map.get(file.urls || %{}, :original)
}
phx-click="download_file"
phx-target={@myself}
phx-value-file-uuid={file.file_uuid}
icon="hero-arrow-down-tray"
label={gettext("Download")}
/>
<.table_row_menu_button
phx-click="prepare_move_file"
phx-target={@myself}
phx-value-file-uuid={file.file_uuid}
icon="hero-folder-arrow-down"
label={gettext("Move")}
/>
<.table_row_menu_button
phx-click="delete_file"
phx-target={@myself}
phx-value-file-uuid={file.file_uuid}
data-confirm={
if @filter_trash,
do:
gettext(
"Permanently delete '%{name}'? This cannot be undone.",
name: file.filename
)
}
icon="hero-trash"
label={
if @filter_trash,
do: gettext("Delete Permanently"),
else: gettext("Move to Trash")
}
variant="error"
/>
</.table_row_menu>
<% end %>
</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>{gettext("Name")}</th>
<th>{gettext("Path")}</th>
<th>{gettext("Type")}</th>
<th>{gettext("Size")}</th>
<th>{gettext("Date")}</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody>
<%!--
All folders rendered below share the same parent
(children of @current_folder, or of @scope when
we're at the scoped root), so the Path column
shows the same string for every row. Pre-compute
once per render — previously each row called the
breadcrumbs walk independently, an N+1 over
identical work.
--%>
<% folders_path = folder_list_path(@current_folder, @scope_folder_id) %>
<%!-- 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-10 h-10" />
</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}
data-draggable-folder={folder.uuid}
data-selected={
if @select_mode &&
MapSet.member?(@selected_folders, folder.uuid),
do: "true"
}
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-10 h-10" />
</span>
</div>
</td>
<td>
<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>
<%= if folders_path do %>
<span class="text-xs text-base-content/50">
{folders_path}
</span>
<% else %>
<span class="text-xs text-base-content/30">/</span>
<% end %>
</td>
<td>
<span class="badge badge-ghost badge-sm h-auto">
{gettext("Folder")}
</span>
</td>
<td></td>
<td class="text-sm text-base-content/70">
{Calendar.strftime(folder.inserted_at, "%b %d, %Y")}
</td>
<td>
<.table_row_menu id={"folder-kebab-list-#{folder.uuid}"}>
<.table_row_menu_button
phx-click="start_rename_folder"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
icon="hero-pencil"
label={gettext("Rename")}
/>
<li role="none">
<div class="px-3 py-1.5">
<span class="text-xs font-semibold text-base-content/50 mb-1 block">
{gettext("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>
<.table_row_menu_button
phx-click="prepare_move_folder"
phx-target={@myself}
phx-value-folder-uuid={folder.uuid}
icon="hero-folder-arrow-down"
label={gettext("Move")}
/>
<.table_row_menu_button
phx-click="delete_folder"
phx-target={@myself}
phx-value-id={folder.uuid}
data-confirm={
gettext("Delete '%{name}'? Files move to parent.",
name: folder.name
)
}
icon="hero-trash"
label={gettext("Delete")}
variant="error"
/>
</.table_row_menu>
</td>
</tr>
<% end %>
<% end %>
<%!-- Files --%>
<%= for file <- @uploaded_files do %>
<tr
data-draggable-file={file.file_uuid}
data-selected={
if @select_mode &&
MapSet.member?(@selected_files, file.file_uuid),
do: "true"
}
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>
<td>
<%!--
Per-file kebab. `phx-click="noop"` on the
wrapper intercepts the bubble so clicks
on the dropdown trigger don't also fire
the row's `click_file`. Same pattern the
folder list-view kebab uses above.
--%>
<%= unless @select_mode do %>
<.table_row_menu id={"file-kebab-list-#{file.file_uuid}"}>
<.table_row_menu_button
:if={
Map.get(file.urls || %{}, "original") ||
Map.get(file.urls || %{}, :original)
}
phx-click="download_file"
phx-target={@myself}
phx-value-file-uuid={file.file_uuid}
icon="hero-arrow-down-tray"
label={gettext("Download")}
/>
<.table_row_menu_button
phx-click="prepare_move_file"
phx-target={@myself}
phx-value-file-uuid={file.file_uuid}
icon="hero-folder-arrow-down"
label={gettext("Move")}
/>
<.table_row_menu_button
phx-click="delete_file"
phx-target={@myself}
phx-value-file-uuid={file.file_uuid}
data-confirm={
if @filter_trash,
do:
gettext(
"Permanently delete '%{name}'? This cannot be undone.",
name: file.filename
)
}
icon="hero-trash"
label={
if @filter_trash,
do: gettext("Delete Permanently"),
else: gettext("Move to Trash")
}
variant="error"
/>
</.table_row_menu>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% end %>
</div>
<%!-- 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" /> {gettext("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"
>
{gettext("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">
<%!--
"item" covers both files and folders so the kebab single-item
and bulk-mixed selections all read sensibly. Bulk-file-only
flows used to say "file(s)" — generalising loses that nuance
but gains correctness when folders are in the mix.
--%>
{ngettext(
"Move %{count} item to folder",
"Move %{count} items to folder",
MapSet.size(@selected_files) + MapSet.size(@selected_folders)
)}
</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">
{gettext("Cancel")}
</button>
</div>
</div>
<div class="modal-backdrop" phx-click="close_move_modal" phx-target={@myself}></div>
</div>
<% end %>
<% end %>
<%!-- ── Read-only modal viewer ──────────────────────────────────────────── --%>
<%!-- Default click target for non-admin, non-select_mode browsers — the --%>
<%!-- click_file handler stashes the clicked file in :viewer_file and this --%>
<%!-- block renders the preview + metadata + Download. Pickers reach --%>
<%!-- bulk-select via the toolbar's Select button (`select_mode` toggle). --%>
<%!-- 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
id={"#{@id}-viewer-modal"}
class="modal modal-open"
phx-hook="ViewerKeydown"
phx-target={@myself}
>
<%!-- Mobile: pin the modal-box directly to the viewport via --%>
<%!-- position: fixed; inset: 0; --%>
<%!-- This bypasses daisyUI's grid layout AND iOS Safari's 100vh / --%>
<%!-- 100dvh quirks — the box just covers top:0 right:0 bottom:0 --%>
<%!-- left:0 of whatever the visual viewport is, no math involved. --%>
<%!-- `w-auto h-auto` lets the inset declarations actually drive the --%>
<%!-- size; otherwise an intrinsic width/height could win. --%>
<%!-- --%>
<%!-- lg+: revert to a normal centred modal with 95vw × 90vh + soft --%>
<%!-- rounded corners. --%>
<%!-- --%>
<%!-- The `!` (important) prefix is required because daisyUI v5's --%>
<%!-- .modal-box defaults (width: calc(11/12 * 100%), max-width: --%>
<%!-- 32rem, max-height: 100vh) win the cascade over plain Tailwind --%>
<%!-- utilities. --%>
<div class="modal-box !fixed !inset-0 !w-auto !h-auto !min-w-0 !max-w-none !max-h-none !m-0 !rounded-none lg:!relative lg:!inset-auto lg:!w-[95vw] lg:!h-[90vh] lg:!rounded-2xl lg:!m-auto p-0 !overflow-hidden">
<%!-- Per-file content (close button, prev/next chevrons, --%>
<%!-- canvas/video/pdf/icon renderer, composer popover, sidebar --%>
<%!-- with filename + Download + metadata + comments thread) is --%>
<%!-- owned by the MediaCanvasViewer child LiveComponent. Its id --%>
<%!-- includes the file uuid so prev/next destroys and remounts --%>
<%!-- on file change (clean canvas re-hydration). --%>
<.live_component
module={PhoenixKitWeb.Components.MediaCanvasViewer}
id={"media-canvas-viewer-" <> f.file_uuid}
file={f}
current_user={assigns[:phoenix_kit_current_user]}
parent_id={@id}
has_prev={has_prev}
has_next={has_next}
/>
</div>
<div class="modal-backdrop" phx-click="close_viewer" phx-target={@myself}></div>
</div>
<% end %>
</div>