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
|
||||
@@ -11,6 +11,12 @@
|
||||
"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}"}>
|
||||
{assigns[:page_title]}
|
||||
</.live_title>
|
||||
|
||||
104
lib/berrypod_web/controllers/favicon_controller.ex
Normal file
104
lib/berrypod_web/controllers/favicon_controller.ex
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -105,6 +105,18 @@ defmodule BerrypodWeb.Router do
|
||||
get "/sitemap.xml", SeoController, :sitemap
|
||||
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)
|
||||
scope "/api", BerrypodWeb do
|
||||
pipe_through [:browser]
|
||||
|
||||
Reference in New Issue
Block a user