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

@@ -6,6 +6,7 @@ defmodule Berrypod.Media do
import Ecto.Query, warn: false
alias Berrypod.Repo
alias Berrypod.Media.Image, as: ImageSchema
alias Berrypod.Media.FaviconVariant
alias Berrypod.Images.Optimizer
alias Berrypod.Images.OptimizeWorker
@@ -170,4 +171,40 @@ defmodule Berrypod.Media do
def list_images_by_type(type) do
Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
end
@doc """
Gets the current icon image (used as favicon source when not using the logo).
"""
def get_icon do
Repo.one(
from i in ImageSchema,
where: i.image_type == "icon",
order_by: [desc: i.inserted_at],
limit: 1
)
end
@doc """
Gets the current favicon variants (single row).
"""
def get_favicon_variants do
Repo.one(
from fv in FaviconVariant,
order_by: [desc: fv.generated_at],
limit: 1
)
end
@doc """
Stores favicon variants, replacing any existing set.
"""
def store_favicon_variants(attrs) do
Repo.delete_all(FaviconVariant)
%FaviconVariant{}
|> FaviconVariant.changeset(
Map.put(attrs, :generated_at, DateTime.utc_now() |> DateTime.truncate(:second))
)
|> Repo.insert()
end
end

View File

@@ -0,0 +1,26 @@
defmodule Berrypod.Media.FaviconVariant do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "favicon_variants" do
field :source_image_id, :binary_id
field :png_32, :binary
field :png_180, :binary
field :png_192, :binary
field :png_512, :binary
field :svg, :string
field :generated_at, :utc_datetime
timestamps(type: :utc_datetime)
end
@doc false
def changeset(variant, attrs) do
variant
|> cast(attrs, [:source_image_id, :png_32, :png_180, :png_192, :png_512, :svg, :generated_at])
|> validate_required([:source_image_id, :generated_at])
end
end

View File

@@ -38,7 +38,7 @@ defmodule Berrypod.Media.Image do
:variants_status
])
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|> validate_inclusion(:image_type, ~w(logo header product))
|> validate_inclusion(:image_type, ~w(logo header product icon))
|> validate_number(:file_size, less_than: @max_file_size)
|> detect_svg()
end

View File

@@ -41,6 +41,12 @@ defmodule Berrypod.Settings.ThemeSettings do
field :product_text_align, :string, default: "left"
field :image_aspect_ratio, :string, default: "square"
# Favicon / site icon
field :use_logo_as_icon, :boolean, default: true
field :icon_image_id, :binary_id
field :favicon_short_name, :string, default: ""
field :icon_background_color, :string, default: "#ffffff"
# Feature toggles
field :announcement_bar, :boolean, default: true
field :sticky_header, :boolean, default: false
@@ -83,6 +89,10 @@ defmodule Berrypod.Settings.ThemeSettings do
:card_shadow,
:product_text_align,
:image_aspect_ratio,
:use_logo_as_icon,
:icon_image_id,
:favicon_short_name,
:icon_background_color,
:announcement_bar,
:sticky_header,
:hover_image,

View File

@@ -0,0 +1,95 @@
defmodule Berrypod.Workers.FaviconGeneratorWorker do
@moduledoc """
Generates favicon variants from a source image.
For raster sources: generates PNG variants at 32, 180, 192, and 512px.
For SVG sources: stores the SVG as-is and attempts PNG generation
(gracefully skipped if libvips lacks SVG support).
"""
use Oban.Worker, queue: :images, max_attempts: 3
require Logger
alias Berrypod.Media
@sizes [512, 192, 180, 32]
@impl Oban.Worker
def perform(%Oban.Job{args: %{"source_image_id" => id}}) do
case Media.get_image(id) do
nil -> {:cancel, :image_not_found}
image -> generate(image)
end
end
defp generate(%{is_svg: true} = image) do
# Try to rasterize the SVG for PNG variants (may fail if libvips lacks SVG support)
png_variants =
case load_svg(image) do
{:ok, vips_image} -> generate_png_variants(vips_image)
{:error, _reason} -> %{}
end
attrs = Map.merge(png_variants, %{source_image_id: image.id, svg: image.svg_content})
Media.store_favicon_variants(attrs)
:ok
end
defp generate(image) do
case Image.from_binary(image.data) do
{:ok, vips_image} ->
png_variants = generate_png_variants(vips_image)
attrs = Map.put(png_variants, :source_image_id, image.id)
Media.store_favicon_variants(attrs)
:ok
{:error, reason} ->
Logger.error("Favicon generation failed: #{inspect(reason)}")
{:error, reason}
end
end
defp load_svg(image) do
Image.from_svg(image.svg_content)
rescue
e ->
Logger.warning("SVG rasterization unavailable: #{Exception.message(e)}")
{:error, :svg_unsupported}
end
defp generate_png_variants(vips_image) do
# Crop to square first if needed
{w, h, _} = Image.shape(vips_image)
side = min(w, h)
vips_image =
if w == h do
vips_image
else
case Image.center_crop(vips_image, side, side) do
{:ok, cropped} -> cropped
{:error, _} -> vips_image
end
end
@sizes
|> Enum.reduce(%{}, fn size, acc ->
case resize_to_png(vips_image, size) do
{:ok, png_data} ->
key = :"png_#{size}"
Map.put(acc, key, png_data)
{:error, reason} ->
Logger.warning("Favicon #{size}px generation failed: #{inspect(reason)}")
acc
end
end)
end
defp resize_to_png(vips_image, size) do
with {:ok, resized} <- Image.thumbnail(vips_image, size),
{:ok, png_data} <- Image.write(resized, :memory, suffix: ".png") do
{:ok, png_data}
end
end
end