diff --git a/PROGRESS.md b/PROGRESS.md index 032f57c..30cdde7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -94,9 +94,9 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m | ~~54~~ | ~~CSV export~~ | 52 | 1.5h | done | | ~~55~~ | ~~Entry/exit pages panel~~ | — | 1h | done | | | **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 | -| 87 | `FaviconController` serving all favicon routes + dynamic `site.webmanifest`; `` tags + `theme-color` meta in `shop_root.html.heex`; default fallback icons | 86 | 1.5h | planned | -| 88 | SVG dark mode injection for SVG-source favicons; icon background colour + short name customisation | 86 | 1h | 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`; `` 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 | done | | | **No-JS support** | | | | | 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 | diff --git a/lib/berrypod/media.ex b/lib/berrypod/media.ex index 66fa40b..4009760 100644 --- a/lib/berrypod/media.ex +++ b/lib/berrypod/media.ex @@ -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 diff --git a/lib/berrypod/media/favicon_variant.ex b/lib/berrypod/media/favicon_variant.ex new file mode 100644 index 0000000..16c88cc --- /dev/null +++ b/lib/berrypod/media/favicon_variant.ex @@ -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 diff --git a/lib/berrypod/media/image.ex b/lib/berrypod/media/image.ex index d38b00e..9f47b83 100644 --- a/lib/berrypod/media/image.ex +++ b/lib/berrypod/media/image.ex @@ -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 diff --git a/lib/berrypod/settings/theme_settings.ex b/lib/berrypod/settings/theme_settings.ex index 445125f..6689f05 100644 --- a/lib/berrypod/settings/theme_settings.ex +++ b/lib/berrypod/settings/theme_settings.ex @@ -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, diff --git a/lib/berrypod/workers/favicon_generator_worker.ex b/lib/berrypod/workers/favicon_generator_worker.ex new file mode 100644 index 0000000..a77ee2f --- /dev/null +++ b/lib/berrypod/workers/favicon_generator_worker.ex @@ -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 diff --git a/lib/berrypod_web/components/layouts/shop_root.html.heex b/lib/berrypod_web/components/layouts/shop_root.html.heex index fad760b..14f63b1 100644 --- a/lib/berrypod_web/components/layouts/shop_root.html.heex +++ b/lib/berrypod_web/components/layouts/shop_root.html.heex @@ -11,6 +11,12 @@ "Welcome to #{@theme_settings.site_name}" } /> + + + + + + <.live_title suffix={" · #{@theme_settings.site_name}"}> {assigns[:page_title]} diff --git a/lib/berrypod_web/controllers/favicon_controller.ex b/lib/berrypod_web/controllers/favicon_controller.ex new file mode 100644 index 0000000..79dae17 --- /dev/null +++ b/lib/berrypod_web/controllers/favicon_controller.ex @@ -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 diff --git a/lib/berrypod_web/live/admin/theme/index.ex b/lib/berrypod_web/live/admin/theme/index.ex index 13e6b25..35f271f 100644 --- a/lib/berrypod_web/live/admin/theme/index.ex +++ b/lib/berrypod_web/live/admin/theme/index.ex @@ -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, diff --git a/lib/berrypod_web/live/admin/theme/index.html.heex b/lib/berrypod_web/live/admin/theme/index.html.heex index ba76524..1309501 100644 --- a/lib/berrypod_web/live/admin/theme/index.html.heex +++ b/lib/berrypod_web/live/admin/theme/index.html.heex @@ -266,6 +266,143 @@ <% end %> + +
+ +

+ Your icon appears in browser tabs and on home screens. +

+ + + + + + <%= if !@theme_settings.use_logo_as_icon do %> +
+ + Upload icon (PNG or SVG, 512×512+) + +
+
+ +
+ <%= if @icon_image do %> +
+ <%= if @icon_image.is_svg do %> + Current icon + <% else %> + Current icon + <% end %> + +
+ <% end %> +
+ + <%= for entry <- @uploads.icon_upload.entries do %> +
+
+
+
+
+ {entry.progress}% + +
+ <%= for err <- upload_errors(@uploads.icon_upload, entry) do %> +

{error_to_string(err)}

+ <% end %> + <% end %> + + <%= for err <- upload_errors(@uploads.icon_upload) do %> +

{error_to_string(err)}

+ <% end %> +
+ <% end %> + + +
+
+
+ Short name + Home screen label +
+ +
+
+ + +
+
+ +
+ + Icon background + + + {@theme_settings.icon_background_color} + +
+
+
+
+