berrypod/lib/berrypod_web/live/admin/theme/index.ex
jamey f788108665
All checks were successful
deploy / deploy (push) Successful in 1m26s
add favicon and site icon generation from uploaded images
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>
2026-02-24 17:22:15 +00:00

567 lines
16 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule BerrypodWeb.Admin.Theme.Index do
use BerrypodWeb, :live_view
alias Berrypod.Settings
alias Berrypod.Media
alias Berrypod.Theme.{CSSGenerator, Presets, PreviewData}
alias Berrypod.Workers.FaviconGeneratorWorker
@impl true
def mount(_params, _session, socket) do
theme_settings = Settings.get_theme_settings()
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
preview_data = %{
products: PreviewData.products(),
cart_items: PreviewData.cart_items(),
testimonials: PreviewData.testimonials(),
categories: PreviewData.categories()
}
logo_image = Media.get_logo()
header_image = Media.get_header()
icon_image = Media.get_icon()
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:preview_page, :home)
|> assign(:presets_with_descriptions, Presets.all_with_descriptions())
|> assign(:active_preset, active_preset)
|> 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)
|> allow_upload(:logo_upload,
accept: ~w(.png .jpg .jpeg .webp .svg),
max_entries: 1,
max_file_size: 2_000_000,
auto_upload: true,
progress: &handle_progress/3
)
|> allow_upload(:header_upload,
accept: ~w(.png .jpg .jpeg .webp),
max_entries: 1,
max_file_size: 5_000_000,
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
defp handle_progress(:logo_upload, entry, socket) do
if entry.done? do
consume_uploaded_entries(socket, :logo_upload, fn %{path: path}, entry ->
case Media.upload_from_entry(path, entry, "logo") do
{:ok, image} ->
Settings.update_theme_settings(%{logo_image_id: image.id})
{:ok, image}
{:error, _} = error ->
error
end
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)}
_ ->
{:noreply, socket}
end
else
{:noreply, socket}
end
end
defp handle_progress(:header_upload, entry, socket) do
if entry.done? do
consume_uploaded_entries(socket, :header_upload, fn %{path: path}, entry ->
case Media.upload_from_entry(path, entry, "header") do
{:ok, image} ->
Settings.update_theme_settings(%{header_image_id: image.id})
{:ok, image}
{:error, _} = error ->
error
end
end)
|> case do
[image | _] ->
{:noreply, assign(socket, :header_image, image)}
_ ->
{:noreply, socket}
end
else
{:noreply, socket}
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)
case Settings.apply_preset(preset_atom) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, preset_atom)
|> put_flash(:info, "Applied #{preset_name} preset")
{:noreply, socket}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to apply preset")}
end
end
@impl true
def handle_event("change_preview_page", %{"page" => page_name}, socket) do
page_atom = String.to_existing_atom(page_name)
socket =
socket
|> assign(:preview_page, page_atom)
|> push_event("scroll-preview-top", %{})
{:noreply, socket}
end
@impl true
def handle_event("update_setting", %{"field" => field, "setting_value" => value}, socket) do
field_atom = String.to_existing_atom(field)
attrs = %{field_atom => value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end
@impl true
def handle_event("update_setting", %{"field" => field} = params, socket) do
# For phx-change events from select/input elements, the value comes from the name attribute
value = params[field] || params["#{field}_text"] || params["value"]
if value do
field_atom = String.to_existing_atom(field)
attrs = %{field_atom => value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("update_color", %{"field" => field, "value" => value}, socket) do
field_atom = String.to_existing_atom(field)
attrs = %{field_atom => value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end
@impl true
def handle_event("toggle_setting", %{"field" => field}, socket) do
field_atom = String.to_existing_atom(field)
current_value = Map.get(socket.assigns.theme_settings, field_atom)
attrs = %{field_atom => !current_value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
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)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end
@impl true
def handle_event("save_theme", _params, socket) do
socket = put_flash(socket, :info, "Theme saved successfully")
{:noreply, socket}
end
@impl true
def handle_event("remove_logo", _params, socket) do
if logo = socket.assigns.logo_image do
Media.delete_image(logo)
end
Settings.update_theme_settings(%{logo_image_id: nil})
socket =
socket
|> assign(:logo_image, nil)
|> put_flash(:info, "Logo removed")
{:noreply, socket}
end
@impl true
def handle_event("remove_header", _params, socket) do
if header = socket.assigns.header_image do
Media.delete_image(header)
end
Settings.update_theme_settings(%{header_image_id: nil})
socket =
socket
|> assign(:header_image, nil)
|> put_flash(:info, "Header image removed")
{: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)
{:noreply, cancel_upload(socket, upload_atom, ref)}
end
@impl true
def handle_event("toggle_customise", _params, socket) do
{:noreply, assign(socket, :customise_open, !socket.assigns.customise_open)}
end
@impl true
def handle_event("toggle_sidebar", _params, socket) do
{:noreply, assign(socket, :sidebar_collapsed, !socket.assigns.sidebar_collapsed)}
end
@impl true
def handle_event("open_cart_drawer", _params, socket) do
{:noreply, assign(socket, :cart_drawer_open, true)}
end
@impl true
def handle_event("close_cart_drawer", _params, socket) do
{:noreply, assign(socket, :cart_drawer_open, false)}
end
@impl true
def handle_event("noop", _params, socket) do
{:noreply, socket}
end
def error_to_string(:too_large), do: "File is too large"
def error_to_string(:too_many_files), do: "Too many files"
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,
products: assigns.preview_data.products,
categories: assigns.preview_data.categories,
cart_items: PreviewData.cart_drawer_items(),
cart_count: 2,
cart_subtotal: "£72.00"
})
end
# Preview page component — delegates to shared PageTemplates with preview-specific assigns
attr :page, :atom, required: true
attr :preview_data, :map, required: true
attr :theme_settings, :map, required: true
attr :logo_image, :any, required: true
attr :header_image, :any, required: true
attr :cart_drawer_open, :boolean, default: false
defp preview_page(%{page: :home} = assigns) do
assigns = preview_assigns(assigns)
~H"<BerrypodWeb.PageTemplates.home {assigns} />"
end
defp preview_page(%{page: :collection} = assigns) do
assigns = preview_assigns(assigns)
~H"<BerrypodWeb.PageTemplates.collection {assigns} />"
end
defp preview_page(%{page: :pdp} = assigns) do
product = List.first(assigns.preview_data.products)
option_types = Map.get(product, :option_types) || []
variants = Map.get(product, :variants) || []
{selected_options, selected_variant} =
case variants do
[first | _] -> {first.options, first}
[] -> {%{}, nil}
end
available_options =
Enum.reduce(option_types, %{}, fn opt, acc ->
values = Enum.map(opt.values, & &1.title)
Map.put(acc, opt.name, values)
end)
display_price =
if selected_variant, do: selected_variant.price, else: product.cheapest_price
assigns =
assigns
|> preview_assigns()
|> assign(:product, product)
|> assign(:gallery_images, build_gallery_images(product))
|> assign(:related_products, Enum.slice(assigns.preview_data.products, 1, 4))
|> assign(:option_types, option_types)
|> assign(:selected_options, selected_options)
|> assign(:available_options, available_options)
|> assign(:display_price, display_price)
|> assign(:quantity, 1)
~H"<BerrypodWeb.PageTemplates.pdp {assigns} />"
end
defp preview_page(%{page: :cart} = assigns) do
cart_items = assigns.preview_data.cart_items
subtotal =
Enum.reduce(cart_items, 0, fn item, acc ->
acc + item.product.cheapest_price * item.quantity
end)
assigns =
assigns
|> preview_assigns()
|> assign(:cart_page_items, cart_items)
|> assign(:cart_page_subtotal, subtotal)
~H"<BerrypodWeb.PageTemplates.cart {assigns} />"
end
defp preview_page(%{page: :about} = assigns) do
assigns =
assigns
|> preview_assigns()
|> assign(%{
active_page: "about",
hero_title: "About the studio",
hero_description: "Your story goes here this is sample content for the demo shop",
hero_background: :sunken,
image_src: "/mockups/night-sky-blanket-3",
image_alt: "Night sky blanket draped over a chair",
content_blocks: PreviewData.about_content()
})
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
end
defp preview_page(%{page: :delivery} = assigns) do
assigns =
assigns
|> preview_assigns()
|> assign(%{
active_page: "delivery",
hero_title: "Delivery & returns",
hero_description: "Everything you need to know about shipping and returns",
content_blocks: PreviewData.delivery_content()
})
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
end
defp preview_page(%{page: :privacy} = assigns) do
assigns =
assigns
|> preview_assigns()
|> assign(%{
active_page: "privacy",
hero_title: "Privacy policy",
hero_description: "How we handle your personal information",
content_blocks: PreviewData.privacy_content()
})
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
end
defp preview_page(%{page: :terms} = assigns) do
assigns =
assigns
|> preview_assigns()
|> assign(%{
active_page: "terms",
hero_title: "Terms of service",
hero_description: "The legal bits",
content_blocks: PreviewData.terms_content()
})
~H"<BerrypodWeb.PageTemplates.content {assigns} />"
end
defp preview_page(%{page: :contact} = assigns) do
assigns = preview_assigns(assigns)
~H"<BerrypodWeb.PageTemplates.contact {assigns} />"
end
defp preview_page(%{page: :error} = assigns) do
assigns =
assigns
|> preview_assigns()
|> assign(%{
error_code: "404",
error_title: "Page Not Found",
error_description:
"Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved."
})
~H"<BerrypodWeb.PageTemplates.error {assigns} />"
end
defp build_gallery_images(product) do
alias Berrypod.Products.ProductImage
(Map.get(product, :images) || [])
|> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|> Enum.reject(&is_nil/1)
|> case do
[] -> []
urls -> urls
end
end
end