add favicon and site icon generation from uploaded images
All checks were successful
deploy / deploy (push) Successful in 1m26s
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:
@@ -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
|
||||
|
||||
26
lib/berrypod/media/favicon_variant.ex
Normal file
26
lib/berrypod/media/favicon_variant.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
95
lib/berrypod/workers/favicon_generator_worker.ex
Normal file
95
lib/berrypod/workers/favicon_generator_worker.ex
Normal 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
|
||||
Reference in New Issue
Block a user