add favicon and site icon generation from uploaded images
All checks were successful
deploy / deploy (push) Successful in 1m26s

Upload a source image (PNG, JPEG, or SVG) and get a complete favicon
setup: PNG variants at 32, 180, 192, 512px served from DB via
FaviconController with ETag caching, SVG favicon for vector sources,
dynamic site.webmanifest, and theme-color meta tag. Theme editor gains
a site icon section with "use logo as icon" toggle, dedicated icon
upload, short name, and background colour picker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-24 17:22:15 +00:00
parent 12d87998ee
commit f788108665
15 changed files with 837 additions and 4 deletions

View File

@@ -4,6 +4,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
alias Berrypod.Settings
alias Berrypod.Media
alias Berrypod.Theme.{CSSGenerator, Presets, PreviewData}
alias Berrypod.Workers.FaviconGeneratorWorker
@impl true
def mount(_params, _session, socket) do
@@ -20,6 +21,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
logo_image = Media.get_logo()
header_image = Media.get_header()
icon_image = Media.get_icon()
socket =
socket
@@ -31,6 +33,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|> assign(:preview_data, preview_data)
|> assign(:logo_image, logo_image)
|> assign(:header_image, header_image)
|> assign(:icon_image, icon_image)
|> assign(:customise_open, false)
|> assign(:sidebar_collapsed, false)
|> assign(:cart_drawer_open, false)
@@ -48,6 +51,13 @@ defmodule BerrypodWeb.Admin.Theme.Index do
auto_upload: true,
progress: &handle_progress/3
)
|> allow_upload(:icon_upload,
accept: ~w(.png .jpg .jpeg .webp .svg),
max_entries: 1,
max_file_size: 5_000_000,
auto_upload: true,
progress: &handle_progress/3
)
{:ok, socket}
end
@@ -66,6 +76,11 @@ defmodule BerrypodWeb.Admin.Theme.Index do
end)
|> case do
[image | _] ->
# Trigger favicon generation if using logo as icon
if socket.assigns.theme_settings.use_logo_as_icon do
enqueue_favicon_generation(image.id)
end
{:noreply, assign(socket, :logo_image, image)}
_ ->
@@ -100,6 +115,31 @@ defmodule BerrypodWeb.Admin.Theme.Index do
end
end
defp handle_progress(:icon_upload, entry, socket) do
if entry.done? do
consume_uploaded_entries(socket, :icon_upload, fn %{path: path}, entry ->
case Media.upload_from_entry(path, entry, "icon") do
{:ok, image} ->
Settings.update_theme_settings(%{icon_image_id: image.id})
{:ok, image}
{:error, _} = error ->
error
end
end)
|> case do
[image | _] ->
enqueue_favicon_generation(image.id)
{:noreply, assign(socket, :icon_image, image)}
_ ->
{:noreply, socket}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("apply_preset", %{"preset" => preset_name}, socket) do
preset_atom = String.to_existing_atom(preset_name)
@@ -221,6 +261,11 @@ defmodule BerrypodWeb.Admin.Theme.Index do
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
# Trigger favicon regeneration when the icon source changes
if field_atom == :use_logo_as_icon do
maybe_enqueue_favicon_from_settings(theme_settings, socket.assigns)
end
socket =
socket
|> assign(:theme_settings, theme_settings)
@@ -272,6 +317,22 @@ defmodule BerrypodWeb.Admin.Theme.Index do
{:noreply, socket}
end
@impl true
def handle_event("remove_icon", _params, socket) do
if icon = socket.assigns.icon_image do
Media.delete_image(icon)
end
Settings.update_theme_settings(%{icon_image_id: nil})
socket =
socket
|> assign(:icon_image, nil)
|> put_flash(:info, "Icon removed")
{:noreply, socket}
end
@impl true
def handle_event("cancel_upload", %{"ref" => ref, "upload" => upload_name}, socket) do
upload_atom = String.to_existing_atom(upload_name)
@@ -308,6 +369,29 @@ defmodule BerrypodWeb.Admin.Theme.Index do
def error_to_string(:not_accepted), do: "File type not accepted"
def error_to_string(err), do: inspect(err)
defp enqueue_favicon_generation(source_image_id) do
%{source_image_id: source_image_id}
|> FaviconGeneratorWorker.new()
|> Oban.insert()
end
defp maybe_enqueue_favicon_from_settings(theme_settings, assigns) do
source_id =
if theme_settings.use_logo_as_icon do
case assigns.logo_image do
%{id: id} -> id
_ -> nil
end
else
case assigns.icon_image do
%{id: id} -> id
_ -> nil
end
end
if source_id, do: enqueue_favicon_generation(source_id)
end
defp preview_assigns(assigns) do
assign(assigns, %{
mode: :preview,

View File

@@ -266,6 +266,143 @@
<% end %>
</div>
<!-- Site Icon / Favicon -->
<div class="bg-base-200 rounded-xl p-4 mb-6">
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-3">
Site icon
</label>
<p class="text-xs text-base-content/50 mb-4">
Your icon appears in browser tabs and on home screens.
</p>
<!-- Use logo as icon toggle -->
<label class="flex items-center gap-2 cursor-pointer mb-4">
<input
type="checkbox"
checked={@theme_settings.use_logo_as_icon}
phx-click="toggle_setting"
phx-value-field="use_logo_as_icon"
class="admin-toggle admin-toggle-sm"
/>
<span class="text-sm text-base-content/70">Use logo as favicon</span>
</label>
<!-- Icon upload (only when not using logo) -->
<%= if !@theme_settings.use_logo_as_icon do %>
<div class="pt-3 border-t border-base-300">
<span class="block text-xs font-medium text-base-content/70 mb-2">
Upload icon (PNG or SVG, 512×512+)
</span>
<div class="flex items-center gap-3">
<form phx-change="noop" phx-submit="noop" class="flex-1">
<label class="flex-1 bg-base-100 border border-dashed border-base-300 rounded-lg p-3 text-sm text-base-content/60 text-center cursor-pointer hover:border-base-content/40 hover:text-base-content/80 transition-all">
<span>Choose file...</span>
<.live_file_input upload={@uploads.icon_upload} class="hidden" />
</label>
</form>
<%= if @icon_image do %>
<div class="relative w-10 h-10 bg-base-100 border border-base-300 rounded-lg flex items-center justify-center overflow-hidden">
<%= if @icon_image.is_svg do %>
<img
src={"/images/#{@icon_image.id}/recolored/000000"}
alt="Current icon"
class="max-w-full max-h-full object-contain"
/>
<% else %>
<img
src={"/image_cache/#{@icon_image.id}.webp"}
alt="Current icon"
class="max-w-full max-h-full object-contain"
/>
<% end %>
<button
type="button"
phx-click="remove_icon"
class="absolute -top-1.5 -right-1.5 w-[18px] h-[18px] bg-base-content text-base-100 rounded-full text-xs flex items-center justify-center leading-none"
title="Remove icon"
>
×
</button>
</div>
<% end %>
</div>
<%= for entry <- @uploads.icon_upload.entries do %>
<div class="flex items-center gap-2 mt-2">
<div class="flex-1 h-1.5 bg-base-300 rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all"
style={"width: #{entry.progress}%"}
>
</div>
</div>
<span class="text-xs text-base-content/60">{entry.progress}%</span>
<button
type="button"
phx-click="cancel_upload"
phx-value-ref={entry.ref}
phx-value-upload="icon_upload"
class="text-base-content/40 hover:text-base-content/70"
>
×
</button>
</div>
<%= for err <- upload_errors(@uploads.icon_upload, entry) do %>
<p class="text-error text-xs mt-1">{error_to_string(err)}</p>
<% end %>
<% end %>
<%= for err <- upload_errors(@uploads.icon_upload) do %>
<p class="text-error text-xs mt-1">{error_to_string(err)}</p>
<% end %>
</div>
<% end %>
<!-- Short name -->
<div class="mt-4 pt-3 border-t border-base-300">
<form phx-change="update_setting" phx-value-field="favicon_short_name">
<div class="flex justify-between items-center mb-1">
<span class="text-xs font-medium text-base-content/70">Short name</span>
<span class="text-xs text-base-content/50">Home screen label</span>
</div>
<input
type="text"
name="favicon_short_name"
value={@theme_settings.favicon_short_name}
placeholder={String.slice(@theme_settings.site_name, 0, 12)}
maxlength="12"
class="w-full px-3 py-2 border border-base-300 rounded-lg text-sm bg-base-100 text-base-content focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all"
/>
</form>
</div>
<!-- Icon background colour -->
<div class="mt-3">
<form
id="icon-bg-color-form"
phx-change="update_color"
phx-value-field="icon_background_color"
phx-hook="ColorSync"
class="flex items-center gap-3"
>
<input
type="color"
name="value"
value={@theme_settings.icon_background_color}
class="w-9 h-9 rounded-lg cursor-pointer border-0 p-0"
/>
<div>
<span class="text-xs font-medium text-base-content/70 block">
Icon background
</span>
<span class="font-mono text-xs text-base-content/50">
{@theme_settings.icon_background_color}
</span>
</div>
</form>
</div>
</div>
<!-- Header Background Toggle -->
<div class="mb-6">
<label class="flex items-center gap-2 cursor-pointer">