Packages
phoenix_kit
1.7.112
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
Current section
Files
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">
<%!-- Close button — top-right of modal-box. Solid bg-base-100 + ring + --%>
<%!-- shadow-lg keeps it visible against any image content (the previous --%>
<%!-- bg-base-100/80 blended into light photos and disappeared). z-30 --%>
<%!-- sits above the chevron buttons (z-10) and the comments thread. --%>
<button
type="button"
phx-click="close_viewer"
phx-target={@myself}
class="btn btn-ghost btn-sm btn-circle absolute top-3 right-3 z-30 bg-base-100 hover:bg-base-200 shadow-lg ring-1 ring-base-300"
title={gettext("Close")}
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
<div class="flex flex-col lg:flex-row h-full overflow-hidden">
<%!-- Viewer (image / video / pdf / icon) --%>
<div class="flex-[7] relative flex items-center justify-center bg-base-200 overflow-hidden p-0 lg:p-2 min-h-[40vh] lg:min-h-0">
<%!-- Chevron positioning sits on a wrapper div, not the button. --%>
<%!-- daisyUI's active-state CSS replaces `transform` with --%>
<%!-- `scale(0.97)` on click, which would clobber a -translate-y-1/2 --%>
<%!-- on the button itself and make it jump down 50% of its height. --%>
<%= if has_prev do %>
<div class="absolute left-2 top-1/2 -translate-y-1/2 z-10">
<button
type="button"
phx-click="step_viewer"
phx-value-dir="prev"
phx-target={@myself}
class="btn btn-circle btn-ghost bg-base-100/80 hover:bg-base-100 shadow-md"
title={gettext("Previous (←)")}
>
<.icon name="hero-chevron-left" class="w-5 h-5" />
</button>
</div>
<% end %>
<%= if has_next do %>
<div class="absolute right-2 top-1/2 -translate-y-1/2 z-10">
<button
type="button"
phx-click="step_viewer"
phx-value-dir="next"
phx-target={@myself}
class="btn btn-circle btn-ghost bg-base-100/80 hover:bg-base-100 shadow-md"
title={gettext("Next (→)")}
>
<.icon name="hero-chevron-right" class="w-5 h-5" />
</button>
</div>
<% end %>
<%= cond do %>
<% is_binary(f.mime_type) and String.starts_with?(f.mime_type, "image/") -> %>
<%!-- Fresco hosts the viewer (pan + zoom + nav + viewport --%>
<%!-- clamping + smooth animations). Tessera attaches as a layer --%>
<%!-- that adds a DZI source provider + multi-layer progressive --%>
<%!-- zoom — starts on `medium` and swaps up as the user zooms: --%>
<%!-- --%>
<%!-- small image → [medium, dzi] --%>
<%!-- 4K+ image → [medium, large, dzi] --%>
<%!-- --%>
<%!-- Tessera 0.2 split the viewer into a Fresco OpenSeadragon --%>
<%!-- viewer + a Tessera layer that swaps between source --%>
<%!-- qualities. Fresco renders the initial cheap source; the --%>
<%!-- Tessera layer attaches via fresco_id and takes over the --%>
<%!-- per-zoom layer-swap logic, preserving viewport bounds. --%>
<%!-- Etcher's annotation overlay attaches to the same Fresco --%>
<%!-- viewer and adds the pencil button to its nav column. --%>
<%= with sources <- tessera_sources(f),
fresco_src <-
(case sources do
[first | _] -> first.url
[] -> f.urls["original"] || f.urls["medium"]
end) do %>
<Fresco.viewer
id={"media-zoom-" <> f.file_uuid}
src={fresco_src}
class="w-full h-full lg:rounded"
rotate
theme={:inherit}
/>
<%!-- Tessera only attaches when there's something to --%>
<%!-- swap between — files without medium/large/dzi --%>
<%!-- variants (variant generator hasn't run yet, etc.) --%>
<%!-- get plain Fresco without progressive zoom. --%>
<%= if sources != [] do %>
<Tessera.layer
fresco_id={"media-zoom-" <> f.file_uuid}
sources={sources}
/>
<% end %>
<%!-- Etcher attaches to any Fresco viewer regardless --%>
<%!-- of Tessera; annotations work even on files that --%>
<%!-- skip the progressive-zoom layer. --%>
<Etcher.layer
fresco_id={"media-zoom-" <> f.file_uuid}
target_type="file"
target_uuid={f.file_uuid}
initial_annotations={@viewer_annotations}
tools={[
:rectangle,
:circle,
:polygon,
:freehand,
:callout,
:text,
:dimension,
:eraser
]}
/>
<% end %>
<% 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"
>
{gettext("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 || gettext("FILE"))}
</p>
<p class="text-sm text-base-content/70 mt-2">{f.mime_type}</p>
</div>
<% end %>
<%!-- Floating annotation composer — anchored to the shape's --%>
<%!-- bottom-left in container px (supplied by etcher.js in --%>
<%!-- the etcher:created event). Sidebar comments stay visible --%>
<%!-- alongside; this popover is for the new shape only. --%>
<%= if @composing_annotation_uuid && assigns[:phoenix_kit_current_user] do %>
<%!-- The AnnotationComposerPosition hook clamps left/top --%>
<%!-- after mount + updates + window resize so the popover --%>
<%!-- never extends past the viewer's right edge or bottom --%>
<%!-- (the server-set anchor is the shape's bottom-left, so a --%>
<%!-- shape drawn near a boundary would otherwise place the --%>
<%!-- popover off-screen). --%>
<div
id={"annotation-composer-popover-" <> @composing_annotation_uuid}
phx-hook="AnnotationComposerPosition"
class="absolute z-30 w-80 max-w-[calc(100%-1rem)] bg-base-100 rounded-lg shadow-xl border border-base-300 p-3"
style={"left: #{(@composer_anchor && @composer_anchor.x) || 16}px; top: #{(@composer_anchor && @composer_anchor.y) || 16}px;"}
>
<div class="flex items-center gap-2 text-sm font-medium mb-2">
<.icon name="hero-pencil-square" class="w-4 h-4 text-warning" />
<span>{gettext("Add a note to your annotation")}</span>
</div>
<p class="text-xs text-base-content/60 mb-3">
{gettext("Post to keep it. Cancel to discard the shape.")}
</p>
<.live_component
module={PhoenixKitWeb.Components.AnnotationComposer}
id={"annotation-composer-" <> @composing_annotation_uuid}
annotation_uuid={@composing_annotation_uuid}
file_uuid={f.file_uuid}
current_user={@phoenix_kit_current_user}
parent_id={@id}
/>
</div>
<% end %>
</div>
<%!-- Sidebar --%>
<div class="flex-[3] flex flex-col gap-3 p-4 overflow-y-auto lg:min-w-[280px] bg-base-100">
<h2 class="text-base font-bold break-all leading-tight pr-10">
{f.filename}
</h2>
<%= 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" /> {gettext("Download")}
</a>
<% end %>
<div class="text-sm space-y-2 mt-1">
<div>
<span class="text-base-content/60">{gettext("Type:")}</span>
<span class="ml-1 badge badge-ghost">
{String.upcase(f.file_type || gettext("FILE"))}
</span>
</div>
<div class="break-all">
<span class="text-base-content/60">{gettext("MIME:")}</span>
<span class="ml-1 font-mono text-xs">{f.mime_type}</span>
</div>
<div>
<span class="text-base-content/60">{gettext("Size:")}</span>
<span class="ml-1">{format_file_size(f.size)}</span>
</div>
<div>
<span class="text-base-content/60">{gettext("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={gettext("Comments")}
/>
</div>
<% end %>
</div>
</div>
</div>
<div class="modal-backdrop" phx-click="close_viewer" phx-target={@myself}></div>
</div>
<% end %>
</div>