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

@ -94,9 +94,9 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| ~~54~~ | ~~CSV export~~ | 52 | 1.5h | done | | ~~54~~ | ~~CSV export~~ | 52 | 1.5h | done |
| ~~55~~ | ~~Entry/exit pages panel~~ | — | 1h | done | | ~~55~~ | ~~Entry/exit pages panel~~ | — | 1h | done |
| | **Favicon & site icons** ([plan](docs/plans/favicon.md)) | | | | | | **Favicon & site icons** ([plan](docs/plans/favicon.md)) | | | |
| 86 | Favicon source upload — `image_type: "icon"`, "use logo as icon" toggle, upload in theme editor, `FaviconGeneratorWorker`, `favicon_variants` table | — | 2.5h | planned | | ~~86~~ | ~~Favicon source upload — `image_type: "icon"`, "use logo as icon" toggle, upload in theme editor, `FaviconGeneratorWorker`, `favicon_variants` table~~ | — | 2.5h | done |
| 87 | `FaviconController` serving all favicon routes + dynamic `site.webmanifest`; `<link>` tags + `theme-color` meta in `shop_root.html.heex`; default fallback icons | 86 | 1.5h | planned | | ~~87~~ | ~~`FaviconController` serving all favicon routes + dynamic `site.webmanifest`; `<link>` tags + `theme-color` meta in `shop_root.html.heex`~~ | 86 | 1.5h | done |
| 88 | SVG dark mode injection for SVG-source favicons; icon background colour + short name customisation | 86 | 1h | planned | | ~~88~~ | ~~SVG dark mode injection for SVG-source favicons; icon background colour + short name customisation~~ | 86 | 1h | done |
| | **No-JS support** | | | | | | **No-JS support** | | | |
| 56 | Audit all key flows for no-JS (browse, cart, checkout, analytics) | — | 2h | planned | | 56 | Audit all key flows for no-JS (browse, cart, checkout, analytics) | — | 2h | planned |
| 57 | Fix any broken flows for no-JS clients | 56 | TBD | planned | | 57 | Fix any broken flows for no-JS clients | 56 | TBD | planned |

View File

@ -6,6 +6,7 @@ defmodule Berrypod.Media do
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Berrypod.Repo alias Berrypod.Repo
alias Berrypod.Media.Image, as: ImageSchema alias Berrypod.Media.Image, as: ImageSchema
alias Berrypod.Media.FaviconVariant
alias Berrypod.Images.Optimizer alias Berrypod.Images.Optimizer
alias Berrypod.Images.OptimizeWorker alias Berrypod.Images.OptimizeWorker
@ -170,4 +171,40 @@ defmodule Berrypod.Media do
def list_images_by_type(type) 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]) Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
end 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 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 :variants_status
]) ])
|> validate_required([:image_type, :filename, :content_type, :file_size, :data]) |> 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) |> validate_number(:file_size, less_than: @max_file_size)
|> detect_svg() |> detect_svg()
end end

View File

@ -41,6 +41,12 @@ defmodule Berrypod.Settings.ThemeSettings do
field :product_text_align, :string, default: "left" field :product_text_align, :string, default: "left"
field :image_aspect_ratio, :string, default: "square" 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 # Feature toggles
field :announcement_bar, :boolean, default: true field :announcement_bar, :boolean, default: true
field :sticky_header, :boolean, default: false field :sticky_header, :boolean, default: false
@ -83,6 +89,10 @@ defmodule Berrypod.Settings.ThemeSettings do
:card_shadow, :card_shadow,
:product_text_align, :product_text_align,
:image_aspect_ratio, :image_aspect_ratio,
:use_logo_as_icon,
:icon_image_id,
:favicon_short_name,
:icon_background_color,
:announcement_bar, :announcement_bar,
:sticky_header, :sticky_header,
:hover_image, :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

View File

@ -11,6 +11,12 @@
"Welcome to #{@theme_settings.site_name}" "Welcome to #{@theme_settings.site_name}"
} }
/> />
<!-- Favicon & PWA -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32x32.png" sizes="32x32" type="image/png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content={@theme_settings.accent_color || "#000000"} />
<.live_title suffix={" · #{@theme_settings.site_name}"}> <.live_title suffix={" · #{@theme_settings.site_name}"}>
{assigns[:page_title]} {assigns[:page_title]}
</.live_title> </.live_title>

View File

@ -0,0 +1,104 @@
defmodule BerrypodWeb.FaviconController do
use BerrypodWeb, :controller
alias Berrypod.Media
alias Berrypod.Settings
@one_day 86_400
def favicon_svg(conn, _params) do
case Media.get_favicon_variants() do
%{svg: svg} when is_binary(svg) ->
conn
|> maybe_not_modified("svg")
|> serve("image/svg+xml", svg)
_ ->
send_resp(conn, 404, "")
end
end
def favicon_32(conn, _params), do: serve_variant(conn, :png_32)
def apple_touch_icon(conn, _params), do: serve_variant(conn, :png_180)
def icon_192(conn, _params), do: serve_variant(conn, :png_192)
def icon_512(conn, _params), do: serve_variant(conn, :png_512)
def webmanifest(conn, _params) do
settings = Settings.get_theme_settings()
short_name =
case settings.favicon_short_name do
name when is_binary(name) and name != "" -> name
_ -> String.slice(settings.site_name, 0, 12)
end
manifest = %{
name: settings.site_name,
short_name: short_name,
theme_color: settings.accent_color || "#000000",
background_color: settings.icon_background_color || "#ffffff",
display: "minimal-ui",
start_url: "/",
icons: [
%{src: "/icon-192.png", sizes: "192x192", type: "image/png"},
%{src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable"}
]
}
conn
|> put_resp_content_type("application/manifest+json")
|> put_resp_header("cache-control", "public, max-age=#{@one_day}")
|> json(manifest)
end
defp serve_variant(conn, field) do
case Media.get_favicon_variants() do
nil ->
send_resp(conn, 404, "")
variants ->
case Map.get(variants, field) do
data when is_binary(data) ->
conn
|> maybe_not_modified(variants)
|> serve("image/png", data)
_ ->
send_resp(conn, 404, "")
end
end
end
defp maybe_not_modified(%{halted: true} = conn, _), do: conn
defp maybe_not_modified(conn, variants) do
etag = build_etag(variants)
case get_req_header(conn, "if-none-match") do
[^etag] ->
conn
|> put_resp_header("cache-control", "public, max-age=#{@one_day}")
|> put_resp_header("etag", etag)
|> send_resp(304, "")
|> halt()
_ ->
conn
|> put_resp_header("cache-control", "public, max-age=#{@one_day}")
|> put_resp_header("etag", etag)
end
end
defp serve(%{halted: true} = conn, _content_type, _data), do: conn
defp serve(conn, content_type, data) do
conn
|> put_resp_content_type(content_type)
|> send_resp(200, data)
end
defp build_etag(%{generated_at: %DateTime{} = dt}),
do: ~s("fav-#{DateTime.to_unix(dt)}")
defp build_etag(_), do: ~s("fav-0")
end

View File

@ -4,6 +4,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
alias Berrypod.Settings alias Berrypod.Settings
alias Berrypod.Media alias Berrypod.Media
alias Berrypod.Theme.{CSSGenerator, Presets, PreviewData} alias Berrypod.Theme.{CSSGenerator, Presets, PreviewData}
alias Berrypod.Workers.FaviconGeneratorWorker
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -20,6 +21,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
logo_image = Media.get_logo() logo_image = Media.get_logo()
header_image = Media.get_header() header_image = Media.get_header()
icon_image = Media.get_icon()
socket = socket =
socket socket
@ -31,6 +33,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|> assign(:preview_data, preview_data) |> assign(:preview_data, preview_data)
|> assign(:logo_image, logo_image) |> assign(:logo_image, logo_image)
|> assign(:header_image, header_image) |> assign(:header_image, header_image)
|> assign(:icon_image, icon_image)
|> assign(:customise_open, false) |> assign(:customise_open, false)
|> assign(:sidebar_collapsed, false) |> assign(:sidebar_collapsed, false)
|> assign(:cart_drawer_open, false) |> assign(:cart_drawer_open, false)
@ -48,6 +51,13 @@ defmodule BerrypodWeb.Admin.Theme.Index do
auto_upload: true, auto_upload: true,
progress: &handle_progress/3 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} {:ok, socket}
end end
@ -66,6 +76,11 @@ defmodule BerrypodWeb.Admin.Theme.Index do
end) end)
|> case do |> case do
[image | _] -> [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)} {:noreply, assign(socket, :logo_image, image)}
_ -> _ ->
@ -100,6 +115,31 @@ defmodule BerrypodWeb.Admin.Theme.Index do
end end
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 @impl true
def handle_event("apply_preset", %{"preset" => preset_name}, socket) do def handle_event("apply_preset", %{"preset" => preset_name}, socket) do
preset_atom = String.to_existing_atom(preset_name) preset_atom = String.to_existing_atom(preset_name)
@ -221,6 +261,11 @@ defmodule BerrypodWeb.Admin.Theme.Index do
generated_css = CSSGenerator.generate(theme_settings) generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(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 =
socket socket
|> assign(:theme_settings, theme_settings) |> assign(:theme_settings, theme_settings)
@ -272,6 +317,22 @@ defmodule BerrypodWeb.Admin.Theme.Index do
{:noreply, socket} {:noreply, socket}
end 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 @impl true
def handle_event("cancel_upload", %{"ref" => ref, "upload" => upload_name}, socket) do def handle_event("cancel_upload", %{"ref" => ref, "upload" => upload_name}, socket) do
upload_atom = String.to_existing_atom(upload_name) 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(:not_accepted), do: "File type not accepted"
def error_to_string(err), do: inspect(err) 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 defp preview_assigns(assigns) do
assign(assigns, %{ assign(assigns, %{
mode: :preview, mode: :preview,

View File

@ -266,6 +266,143 @@
<% end %> <% end %>
</div> </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 --> <!-- Header Background Toggle -->
<div class="mb-6"> <div class="mb-6">
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">

View File

@ -105,6 +105,18 @@ defmodule BerrypodWeb.Router do
get "/sitemap.xml", SeoController, :sitemap get "/sitemap.xml", SeoController, :sitemap
end end
# Favicon & PWA manifest — served from DB, minimal pipeline
scope "/", BerrypodWeb do
pipe_through [:seo]
get "/favicon.svg", FaviconController, :favicon_svg
get "/favicon-32x32.png", FaviconController, :favicon_32
get "/apple-touch-icon.png", FaviconController, :apple_touch_icon
get "/icon-192.png", FaviconController, :icon_192
get "/icon-512.png", FaviconController, :icon_512
get "/site.webmanifest", FaviconController, :webmanifest
end
# Cart API (session persistence for LiveView) # Cart API (session persistence for LiveView)
scope "/api", BerrypodWeb do scope "/api", BerrypodWeb do
pipe_through [:browser] pipe_through [:browser]

View File

@ -0,0 +1,18 @@
defmodule Berrypod.Repo.Migrations.CreateFaviconVariants do
use Ecto.Migration
def change do
create table(:favicon_variants, primary_key: false) do
add :id, :binary_id, primary_key: true
add :source_image_id, references(:images, type: :binary_id, on_delete: :nilify_all)
add :png_32, :binary
add :png_180, :binary
add :png_192, :binary
add :png_512, :binary
add :svg, :text
add :generated_at, :utc_datetime
timestamps(type: :utc_datetime)
end
end
end

View File

@ -46,6 +46,12 @@ defmodule Berrypod.MediaTest do
assert "is invalid" in errors_on(changeset).image_type assert "is invalid" in errors_on(changeset).image_type
end end
test "accepts icon image type" do
attrs = Map.put(@valid_attrs, :image_type, "icon")
assert {:ok, image} = Media.upload_image(attrs)
assert image.image_type == "icon"
end
test "validates file size" do test "validates file size" do
attrs = Map.put(@valid_attrs, :file_size, 10_000_000) attrs = Map.put(@valid_attrs, :file_size, 10_000_000)
assert {:error, changeset} = Media.upload_image(attrs) assert {:error, changeset} = Media.upload_image(attrs)
@ -110,4 +116,77 @@ defmodule Berrypod.MediaTest do
assert Media.list_images_by_type("product") == [] assert Media.list_images_by_type("product") == []
end end
end end
describe "get_icon/0" do
test "returns the most recent icon image" do
attrs = Map.put(@valid_attrs, :image_type, "icon")
{:ok, icon} = Media.upload_image(attrs)
result = Media.get_icon()
assert result.id == icon.id
assert result.image_type == "icon"
end
test "returns nil when no icons exist" do
assert Media.get_icon() == nil
end
end
describe "favicon variants" do
defp create_source_image do
{:ok, image} = Media.upload_image(@valid_attrs)
image
end
test "stores and retrieves favicon variants" do
image = create_source_image()
{:ok, variants} =
Media.store_favicon_variants(%{
source_image_id: image.id,
png_32: <<1, 2, 3>>,
png_180: <<4, 5, 6>>,
png_192: <<7, 8, 9>>,
png_512: <<10, 11, 12>>
})
assert variants.png_32 == <<1, 2, 3>>
fetched = Media.get_favicon_variants()
assert fetched.id == variants.id
assert fetched.png_32 == <<1, 2, 3>>
assert fetched.png_512 == <<10, 11, 12>>
end
test "replaces existing variants on store" do
image = create_source_image()
{:ok, first} =
Media.store_favicon_variants(%{
source_image_id: image.id,
png_32: <<1>>,
png_180: <<2>>,
png_192: <<3>>,
png_512: <<4>>
})
{:ok, second} =
Media.store_favicon_variants(%{
source_image_id: image.id,
png_32: <<5>>,
png_180: <<6>>,
png_192: <<7>>,
png_512: <<8>>
})
assert second.id != first.id
fetched = Media.get_favicon_variants()
assert fetched.id == second.id
assert fetched.png_32 == <<5>>
end
test "returns nil when no variants exist" do
assert Media.get_favicon_variants() == nil
end
end
end end

View File

@ -0,0 +1,88 @@
defmodule Berrypod.Workers.FaviconGeneratorWorkerTest do
use Berrypod.DataCase, async: false
import Berrypod.ImageFixtures
alias Berrypod.Media
alias Berrypod.Workers.FaviconGeneratorWorker
describe "perform/1" do
test "generates PNG variants from a raster image" do
image = image_fixture(%{image_type: "icon"})
assert :ok =
FaviconGeneratorWorker.perform(%Oban.Job{
args: %{"source_image_id" => image.id}
})
variants = Media.get_favicon_variants()
assert variants != nil
assert variants.source_image_id == image.id
assert is_binary(variants.png_32)
assert is_binary(variants.png_180)
assert is_binary(variants.png_192)
assert is_binary(variants.png_512)
assert variants.svg == nil
assert variants.generated_at != nil
end
test "generates correct PNG sizes" do
image = image_fixture(%{image_type: "icon"})
:ok =
FaviconGeneratorWorker.perform(%Oban.Job{
args: %{"source_image_id" => image.id}
})
variants = Media.get_favicon_variants()
# Verify PNG data starts with PNG magic bytes
for field <- [:png_32, :png_180, :png_192, :png_512] do
data = Map.get(variants, field)
assert <<137, 80, 78, 71, _rest::binary>> = data, "#{field} should be valid PNG"
end
end
test "stores SVG as-is for SVG source" do
image = svg_fixture()
:ok =
FaviconGeneratorWorker.perform(%Oban.Job{
args: %{"source_image_id" => image.id}
})
variants = Media.get_favicon_variants()
assert variants != nil
assert is_binary(variants.svg)
assert variants.svg == image.svg_content
end
test "replaces existing variants on regeneration" do
image = image_fixture(%{image_type: "icon"})
:ok =
FaviconGeneratorWorker.perform(%Oban.Job{
args: %{"source_image_id" => image.id}
})
first_variants = Media.get_favicon_variants()
first_id = first_variants.id
# Generate again
:ok =
FaviconGeneratorWorker.perform(%Oban.Job{
args: %{"source_image_id" => image.id}
})
second_variants = Media.get_favicon_variants()
assert second_variants.id != first_id
end
test "cancels when image not found" do
assert {:cancel, :image_not_found} =
FaviconGeneratorWorker.perform(%Oban.Job{
args: %{"source_image_id" => Ecto.UUID.generate()}
})
end
end
end

View File

@ -0,0 +1,137 @@
defmodule BerrypodWeb.FaviconControllerTest do
use BerrypodWeb.ConnCase, async: false
alias Berrypod.Media
# Minimal valid PNG (1x1 transparent)
@test_png <<137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0,
1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68, 65, 84, 120, 156, 98, 0, 0,
0, 2, 0, 1, 226, 33, 188, 51, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130>>
@test_svg ~s(<svg xmlns="http://www.w3.org/2000/svg"><circle r="10"/></svg>)
defp create_source_image do
{:ok, image} =
Media.upload_image(%{
image_type: "icon",
filename: "icon.svg",
content_type: "image/svg+xml",
file_size: byte_size(@test_svg),
data: @test_svg
})
image
end
defp create_favicon_variants(_context) do
image = create_source_image()
{:ok, variants} =
Media.store_favicon_variants(%{
source_image_id: image.id,
png_32: @test_png,
png_180: @test_png,
png_192: @test_png,
png_512: @test_png,
svg: @test_svg
})
%{variants: variants}
end
describe "favicon routes with variants" do
setup [:create_favicon_variants]
test "serves favicon SVG", %{conn: conn} do
conn = get(conn, ~p"/favicon.svg")
assert response_content_type(conn, :xml) =~ "image/svg+xml"
assert conn.status == 200
assert conn.resp_body =~ "<svg"
assert get_resp_header(conn, "cache-control") == ["public, max-age=86400"]
assert [etag] = get_resp_header(conn, "etag")
assert etag =~ ~r/^"fav-\d+"$/
end
test "serves 32x32 PNG favicon", %{conn: conn} do
conn = get(conn, ~p"/favicon-32x32.png")
assert conn.status == 200
assert response_content_type(conn, :png) =~ "image/png"
assert <<137, 80, 78, 71, _::binary>> = conn.resp_body
end
test "serves apple touch icon", %{conn: conn} do
conn = get(conn, ~p"/apple-touch-icon.png")
assert conn.status == 200
assert response_content_type(conn, :png) =~ "image/png"
end
test "serves 192px icon", %{conn: conn} do
conn = get(conn, ~p"/icon-192.png")
assert conn.status == 200
assert response_content_type(conn, :png) =~ "image/png"
end
test "serves 512px icon", %{conn: conn} do
conn = get(conn, ~p"/icon-512.png")
assert conn.status == 200
assert response_content_type(conn, :png) =~ "image/png"
end
test "returns 304 for matching ETag", %{conn: conn} do
conn1 = get(conn, ~p"/favicon-32x32.png")
[etag] = get_resp_header(conn1, "etag")
conn2 =
conn
|> put_req_header("if-none-match", etag)
|> get(~p"/favicon-32x32.png")
assert conn2.status == 304
end
end
describe "favicon routes without variants" do
test "returns 404 for favicon SVG", %{conn: conn} do
conn = get(conn, ~p"/favicon.svg")
assert conn.status == 404
end
test "returns 404 for PNG variants", %{conn: conn} do
assert get(conn, ~p"/favicon-32x32.png").status == 404
assert get(conn, ~p"/apple-touch-icon.png").status == 404
assert get(conn, ~p"/icon-192.png").status == 404
assert get(conn, ~p"/icon-512.png").status == 404
end
end
describe "webmanifest" do
test "returns valid JSON manifest", %{conn: conn} do
conn = get(conn, ~p"/site.webmanifest")
assert conn.status == 200
assert response_content_type(conn, :json) =~ "application/manifest+json"
manifest = json_response(conn, 200)
assert manifest["name"] == "Store Name"
assert manifest["display"] == "minimal-ui"
assert manifest["start_url"] == "/"
assert is_binary(manifest["theme_color"])
assert is_binary(manifest["background_color"])
assert length(manifest["icons"]) == 2
end
test "manifest icons have correct sizes", %{conn: conn} do
manifest = json_response(get(conn, ~p"/site.webmanifest"), 200)
icons = manifest["icons"]
assert Enum.any?(icons, &(&1["sizes"] == "192x192"))
assert Enum.any?(icons, &(&1["sizes"] == "512x512"))
end
test "manifest uses short name from settings", %{conn: conn} do
manifest = json_response(get(conn, ~p"/site.webmanifest"), 200)
# Default: truncated site_name (no favicon_short_name set)
assert is_binary(manifest["short_name"])
assert String.length(manifest["short_name"]) <= 12
end
end
end