Packages

phoenix_kit

1.7.194
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 modules storage web settings.html.heex
Raw

lib/modules/storage/web/settings.html.heex

<PhoenixKitWeb.Components.LayoutWrapper.app_layout
flash={@flash}
phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]}
page_title={gettext("Media Settings")}
current_path={@current_path}
current_locale={@current_locale}
project_title={@project_title}
>
<div class="container mx-auto px-4 py-8">
<.admin_page_header
back={PhoenixKit.Utils.Routes.path("/admin/modules")}
title={gettext("Media Settings")}
subtitle={
gettext(
"Media is a core system module that is always enabled and cannot be disabled — it provides essential file management functionality across PhoenixKit. Configure its storage buckets and file processing settings here."
)
}
/>
<%!-- System Dependencies Check --%>
<%= if match?({:error, :not_installed}, @imagemagick_status) do %>
<div class="alert alert-error mb-6">
<.icon name="hero-exclamation-circle" class="w-5 h-5" />
<div>
<h3 class="font-bold">{gettext("ImageMagick Not Installed")}</h3>
<p class="text-sm">
{gettext("ImageMagick is required for image processing and variant generation.")}
{gettext("Without it, image resizing, cropping, and format conversion will not work.")}
</p>
<p class="text-sm mt-2">
<strong>{gettext("Installation:")}</strong>
<br />macOS:
<code class="bg-base-200 px-2 py-1 rounded text-xs">brew install imagemagick</code>
<br />Ubuntu/Debian:
<code class="bg-base-200 px-2 py-1 rounded text-xs">
sudo apt-get install imagemagick
</code>
<br />CentOS/RHEL:
<code class="bg-base-200 px-2 py-1 rounded text-xs">
sudo yum install ImageMagick
</code>
</p>
</div>
</div>
<% end %>
<%= if match?({:error, :not_installed}, @ffmpeg_status) do %>
<div class="alert alert-warning mb-6">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div>
<h3 class="font-bold">{gettext("FFmpeg Not Installed")}</h3>
<p class="text-sm">
{gettext(
"FFmpeg is required for video processing, transcoding, and thumbnail generation."
)}
{gettext("Without it, video file processing will not work.")}
</p>
<p class="text-sm mt-2">
<strong>{gettext("Installation:")}</strong>
<br />macOS:
<code class="bg-base-200 px-2 py-1 rounded text-xs">brew install ffmpeg</code>
<br />Ubuntu/Debian:
<code class="bg-base-200 px-2 py-1 rounded text-xs">sudo apt-get install ffmpeg</code>
<br />CentOS/RHEL:
<code class="bg-base-200 px-2 py-1 rounded text-xs">sudo yum install ffmpeg</code>
</p>
</div>
</div>
<% end %>
<%!-- Media Buckets --%>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title text-lg">
<.icon name="hero-inbox-stack" class="w-6 h-6 mr-2" /> {gettext("Media Buckets")}
</h2>
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/settings/media/buckets/new")}
class="btn btn-primary"
>
<.icon name="hero-plus" class="w-4 h-4 mr-1" /> {gettext("Add Bucket")}
</.link>
</div>
<%= if Enum.empty?(@buckets) do %>
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div>
<h3 class="font-bold">{gettext("No Media Buckets Configured")}</h3>
<p class="text-sm">
{gettext(
"You need to create at least one media bucket before files can be uploaded."
)}
{gettext(
"Buckets can be local directories or cloud storage providers (AWS S3, Backblaze B2, Cloudflare R2)."
)}
</p>
<div class="mt-3">
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/settings/media/buckets/new")}
class="btn btn-warning btn-sm"
>
<.icon name="hero-plus" class="w-4 h-4 mr-1" /> {gettext(
"Create Your First Bucket"
)}
</.link>
</div>
</div>
</div>
<% else %>
<.table_default
id="buckets-table"
variant="zebra"
toggleable={true}
items={@buckets}
card_title={fn bucket -> bucket.name end}
card_fields={
fn bucket ->
[
%{
label: gettext("Provider"),
value: String.upcase(bucket.provider)
},
%{
label: gettext("Priority"),
value:
if(bucket.priority == 0,
do: gettext("Random"),
else: to_string(bucket.priority)
)
},
%{
label: gettext("Files"),
value: "#{Map.get(@bucket_file_counts, bucket.uuid, 0)}"
},
%{
label: gettext("Status"),
value: if(bucket.enabled, do: gettext("Enabled"), else: gettext("Disabled"))
}
]
end
}
>
<.table_default_header>
<.table_default_row>
<.table_default_header_cell>{gettext("Name")}</.table_default_header_cell>
<.table_default_header_cell>{gettext("Provider")}</.table_default_header_cell>
<.table_default_header_cell>{gettext("Priority")}</.table_default_header_cell>
<.table_default_header_cell>{gettext("Files")}</.table_default_header_cell>
<.table_default_header_cell>{gettext("Status")}</.table_default_header_cell>
<.table_default_header_cell>{gettext("Actions")}</.table_default_header_cell>
</.table_default_row>
</.table_default_header>
<.table_default_body>
<%= for bucket <- @buckets do %>
<.table_default_row>
<.table_default_cell>
<div class="flex items-center gap-2">
<%= case bucket.provider do %>
<% "local" -> %>
<.icon name="hero-folder" class="w-5 h-5 text-primary" />
<% "s3" -> %>
<.icon name="hero-cloud" class="w-5 h-5 text-warning" />
<% "b2" -> %>
<.icon name="hero-cube" class="w-5 h-5 text-info" />
<% "r2" -> %>
<.icon name="hero-cloud-arrow-up" class="w-5 h-5 text-accent" />
<% "tigris" -> %>
<.icon name="hero-globe-alt" class="w-5 h-5 text-secondary" />
<% _ -> %>
<.icon name="hero-server" class="w-5 h-5 text-base-content/50" />
<% end %>
<div>
<div class="font-bold">{bucket.name}</div>
<div class="text-xs text-base-content/60 font-mono">
{get_bucket_full_path(bucket)}
</div>
</div>
</div>
</.table_default_cell>
<.table_default_cell>
<span class="badge badge-ghost badge-sm h-auto">
{String.upcase(bucket.provider)}
</span>
</.table_default_cell>
<.table_default_cell>
<%= if bucket.priority == 0 do %>
<span class="text-xs text-base-content/60">{gettext("Random")} (0)</span>
<% else %>
<span class="font-semibold">{bucket.priority}</span>
<% end %>
</.table_default_cell>
<.table_default_cell>
<span class="text-sm font-semibold">
{Map.get(@bucket_file_counts, bucket.uuid, 0)} {gettext("files")}
</span>
</.table_default_cell>
<.table_default_cell>
<%= if bucket.enabled do %>
<span class="badge badge-success badge-sm h-auto">
{gettext("Enabled")}
</span>
<% else %>
<span class="badge badge-error badge-sm h-auto">
{gettext("Disabled")}
</span>
<% end %>
</.table_default_cell>
<.table_default_cell>
<.table_row_menu
id={"bucket-menu-#{bucket.uuid}"}
label={gettext("Bucket actions")}
>
<.table_row_menu_link
navigate={
PhoenixKit.Utils.Routes.path(
"/admin/settings/media/buckets/#{bucket.uuid}/edit"
)
}
icon="hero-pencil"
label={gettext("Edit bucket")}
/>
<.table_row_menu_button
phx-click="toggle_bucket"
phx-value-id={bucket.uuid}
icon={if bucket.enabled, do: "hero-eye-slash", else: "hero-eye"}
label={if bucket.enabled, do: gettext("Disable"), else: gettext("Enable")}
/>
<.table_row_menu_button
phx-click="delete_bucket"
phx-value-id={bucket.uuid}
data-confirm={
gettext(
"Are you sure you want to delete this bucket? This will remove all location records."
)
}
icon="hero-trash"
label={gettext("Delete bucket")}
class="text-error"
/>
</.table_row_menu>
</.table_default_cell>
</.table_default_row>
<% end %>
</.table_default_body>
<:card_actions :let={bucket}>
<.link
navigate={
PhoenixKit.Utils.Routes.path(
"/admin/settings/media/buckets/#{bucket.uuid}/edit"
)
}
class="btn btn-xs btn-outline btn-info"
>
<.icon name="hero-pencil" class="w-3 h-3" /> {gettext("Edit")}
</.link>
<button
phx-click="toggle_bucket"
phx-value-id={bucket.uuid}
class="btn btn-xs btn-outline"
>
{if bucket.enabled, do: gettext("Disable"), else: gettext("Enable")}
</button>
<button
phx-click="delete_bucket"
phx-value-id={bucket.uuid}
data-confirm={
gettext(
"Are you sure you want to delete this bucket? This will remove all location records."
)
}
class="btn btn-xs btn-outline btn-error"
>
<.icon name="hero-trash" class="w-3 h-3" /> {gettext("Delete")}
</button>
</:card_actions>
</.table_default>
<div class="stats stats-vertical lg:stats-horizontal shadow mt-6">
<div class="stat">
<div class="stat-title">{gettext("Total Buckets")}</div>
<div class="stat-value text-2xl">{length(@buckets)}</div>
<div class="stat-desc">{gettext("Configured media locations")}</div>
</div>
<div class="stat">
<div class="stat-title">{gettext("Active Buckets")}</div>
<div class="stat-value text-2xl text-success">
{Enum.count(@buckets, & &1.enabled)}
</div>
<div class="stat-desc">{gettext("Currently in use")}</div>
</div>
<div class="stat">
<div class="stat-title">{gettext("Default Bucket")}</div>
<div class="stat-value text-2xl">
<%= if @default_bucket_uuid do %>
<span class="text-success">{gettext("Set")}</span>
<% else %>
<span class="text-warning">{gettext("None")}</span>
<% end %>
</div>
<div class="stat-desc">{gettext("Primary media location")}</div>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Media Configuration --%>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-lg mb-4">
<.icon name="hero-cog-6-tooth" class="w-6 h-6 mr-2" /> {gettext("Media Configuration")}
</h2>
<.form
for={%{"form_redundancy" => @form_redundancy}}
phx-change="update_storage_form"
class="grid grid-cols-1 md:grid-cols-2 gap-6"
>
<%!-- Redundancy Settings --%>
<div class="form-control">
<div class="mb-2">
<span class="label-text font-semibold">{gettext("Redundancy Copies")}</span>
<span class="block text-xs text-base-content/60">
{gettext("Number of copies to store")}
</span>
</div>
<%= if @active_buckets_count == 0 do %>
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-4 h-4" />
<span class="text-sm">
{gettext("No active buckets available. Enable buckets to configure redundancy.")}
</span>
</div>
<% else %>
<%= if @form_redundancy > @max_redundancy do %>
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-4 h-4" />
<span class="text-sm">
{gettext(
"Current setting (%{count} copies) exceeds available buckets (%{max}).",
count: @form_redundancy,
max: @max_redundancy
)} {gettext("Reduce redundancy or enable more buckets.")}
</span>
</div>
<% end %>
<input
type="number"
name="form_redundancy"
value={@form_redundancy}
min="1"
max={@max_redundancy}
class={"input input-bordered #{if @form_redundancy > @max_redundancy, do: "input-warning", else: ""}"}
/>
<p class="text-xs text-base-content/70 mt-1">
{gettext("Enter number of copies")} (1-{@max_redundancy}).
<%= if @max_redundancy == 1 do %>
{gettext(
"Only 1 active bucket available. Add more buckets to enable redundancy."
)}
<% else %>
{gettext("Maximum %{count} copies based on %{buckets} active bucket(s).",
count: @max_redundancy,
buckets: @active_buckets_count
)}
<% end %>
</p>
<% end %>
</div>
<%!-- Auto-Variant Generation --%>
<div class="form-control">
<label class="flex items-start gap-3 cursor-pointer py-2">
<input
type="checkbox"
name="form_auto_generate_variants"
checked={@form_auto_generate_variants}
phx-click="toggle_form_variants"
value="true"
class="checkbox checkbox-primary mt-0.5 flex-shrink-0"
/>
<span>
<span class="font-semibold text-sm">{gettext("Auto-Generate Variants")}</span>
<span class="block text-xs text-base-content/70 mt-0.5">
{gettext("Automatically resize images and videos to configured dimensions")}
</span>
</span>
</label>
</div>
<%!-- Deep Zoom Tile Generation --%>
<div class="form-control">
<label class="flex items-start gap-3 cursor-pointer py-2">
<input
type="checkbox"
name="form_tile_generation_enabled"
checked={@form_tile_generation_enabled}
phx-click="toggle_form_tile_generation"
value="true"
class="checkbox checkbox-primary mt-0.5 flex-shrink-0"
/>
<span>
<span class="font-semibold text-sm">
{gettext("Deep Zoom Tile Generation")}
</span>
<span class="block text-xs text-base-content/70 mt-0.5">
{gettext(
"Lazily generate DZI tile pyramids when users zoom deeply into images. With this off the MediaBrowser still swaps between medium/large variants while zooming, but never generates tiles or hits the lazy tile endpoint. ImageMagick is required when enabled."
)}
</span>
</span>
</label>
</div>
<%!-- Annotated Thumbnails --%>
<div class="form-control">
<label class="flex items-start gap-3 cursor-pointer py-2">
<input
type="checkbox"
name="form_annotated_thumbnails_enabled"
checked={@form_annotated_thumbnails_enabled}
phx-click="toggle_form_annotated_thumbnails"
value="true"
class="checkbox checkbox-primary mt-0.5 flex-shrink-0"
/>
<span>
<span class="font-semibold text-sm">
{gettext("Annotated Thumbnails")}
</span>
<span class="block text-xs text-base-content/70 mt-0.5">
{gettext(
"Bake Etcher annotation shapes (rectangles, marker strokes, etc.) into a file's grid thumbnail, regenerated in the background when annotations change. With this off the grid shows the plain thumbnail and no annotated variant is generated. ImageMagick is required when enabled."
)}
</span>
</span>
</label>
</div>
<%!-- Max Upload Size --%>
<div class="form-control">
<div class="mb-2">
<span class="label-text font-semibold">{gettext("Max Upload Size (MB)")}</span>
<span class="block text-xs text-base-content/60">
{gettext("Maximum file size allowed per upload")}
</span>
</div>
<input
type="number"
name="form_max_upload_size_mb"
value={@form_max_upload_size_mb}
min="1"
class="input input-bordered"
/>
<p class="text-xs text-base-content/70 mt-1">
{gettext("Current limit: %{size} MB", size: @form_max_upload_size_mb)}
</p>
</div>
</.form>
<%!-- Apply Button --%>
<% storage_form_clean =
@form_redundancy == @redundancy_copies and
@form_auto_generate_variants == @auto_generate_variants and
@form_tile_generation_enabled == @tile_generation_enabled and
@form_annotated_thumbnails_enabled == @annotated_thumbnails_enabled and
@form_max_upload_size_mb == @max_upload_size_mb %>
<div class="form-control mt-4">
<button
type="button"
phx-click="apply_storage_settings"
disabled={storage_form_clean}
phx-disable-with={gettext("Applying...")}
class="btn btn-primary"
>
<.icon name="hero-check" class="w-4 h-4 mr-2" /> {gettext("Apply Changes")}
</button>
<span class="label-text-alt text-xs ml-2">
<%= if storage_form_clean do %>
{gettext("No changes to apply")}
<% else %>
{gettext("Changes will be saved immediately")}
<% end %>
</span>
</div>
<%!-- Quick Actions --%>
<div class="divider">{gettext("Quick Actions")}</div>
<%!--
Each action is wrapped in a daisyUI tooltip (rather than putting the
tooltip on the button) so the hover description also works on the
disabled "Media Stats" button — a disabled button has
`pointer-events: none` and would otherwise swallow hover. Each button
also carries a trailing info icon as the visible "what does this do?"
affordance.
--%>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<%!-- Manage Dimensions --%>
<div
class="tooltip tooltip-bottom"
data-tip={
gettext(
"Create and edit the image/video size presets used to generate variants of uploaded media."
)
}
>
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/settings/media/dimensions")}
class="btn btn-outline w-full"
>
<.icon name="hero-photo" class="w-4 h-4 mr-2" /> {gettext("Manage Dimensions")}
<.icon name="hero-information-circle" class="w-4 h-4 ml-1 opacity-50" />
</.link>
</div>
<%!-- Media Stats (coming soon) --%>
<div
class="tooltip tooltip-bottom"
data-tip={gettext("View storage usage and media statistics. Coming soon.")}
>
<button class="btn btn-outline w-full" disabled>
<.icon name="hero-chart-bar" class="w-4 h-4 mr-2" /> {gettext("Media Stats")}
<span class="badge badge-xs ml-1">{gettext("Soon")}</span>
<.icon name="hero-information-circle" class="w-4 h-4 ml-1 opacity-50" />
</button>
</div>
<%!-- Sync Files / Health dashboard --%>
<div
class="tooltip tooltip-bottom"
data-tip={
gettext(
"Open the media health dashboard to check file redundancy and re-sync files across storage locations."
)
}
>
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/settings/media/health")}
class="btn btn-outline w-full"
>
<.icon name="hero-arrow-path" class="w-4 h-4 mr-2" /> {gettext("Sync Files")}
<.icon name="hero-information-circle" class="w-4 h-4 ml-1 opacity-50" />
</.link>
</div>
<%!-- Find Orphaned Files --%>
<div
class="tooltip tooltip-bottom"
data-tip={
gettext(
"Browse files no longer attached to any record (orphaned media) so you can review or delete them."
)
}
>
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/media") <> "?orphaned=1"}
class="btn btn-outline w-full"
>
<.icon name="hero-funnel" class="w-4 h-4 mr-2" /> {gettext("Find Orphaned Files")}
<.icon name="hero-information-circle" class="w-4 h-4 ml-1 opacity-50" />
</.link>
</div>
<%!-- Repair Media Module --%>
<div
class="tooltip tooltip-bottom"
data-tip={
gettext(
"Reset dimensions and settings to defaults and create a local bucket if none exist. Does not delete uploaded files."
)
}
>
<button
phx-click="repair_storage_module"
data-confirm={
gettext(
"This will reset dimensions and settings to defaults, and create a local bucket if none exist. Your uploaded files will NOT be deleted. Continue?"
)
}
class="btn btn-outline btn-warning w-full"
>
<.icon name="hero-wrench-screwdriver" class="w-4 h-4 mr-2" /> {gettext(
"Repair Media Module"
)}
<.icon name="hero-information-circle" class="w-4 h-4 ml-1 opacity-50" />
</button>
</div>
</div>
</div>
</div>
<%!-- Help Section --%>
<div class="alert mt-6">
<.icon name="hero-light-bulb" class="w-5 h-5" />
<div>
<h3 class="font-bold">{gettext("Media System Architecture")}</h3>
<p class="text-sm">
{gettext(
"The media system is designed for distributed file management with automatic redundancy."
)}
{gettext(
"Files are tracked in PostgreSQL with support for multiple media locations (local, S3, remote servers)."
)}
{gettext(
"When a file is requested, the system automatically tries each location until it successfully retrieves the file."
)}
</p>
</div>
</div>
</div>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>