berrypod/lib/berrypod_web/live/admin/theme/index.ex
jamey 93ff66debc
Some checks failed
deploy / deploy (push) Has been cancelled
add legal page editor integration and media library polish
Legal pages (privacy, delivery, terms) now auto-populate content from
shop settings on mount, show auto-generated vs customised badges, and
have a regenerate button. Theme editor gains alt text fields for logo,
header, and icon images. Image picker in page builder now has an upload
button and alt text warning badges. Clearing unused image references
shows an orphan info flash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:55:02 +00:00

547 lines
16 KiB
Elixir

defmodule BerrypodWeb.Admin.Theme.Index do
use BerrypodWeb, :live_view
alias Berrypod.{Pages, 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("update_image_alt", %{"image-id" => image_id, "alt" => alt}, socket) do
case Media.get_image(image_id) do
nil ->
{:noreply, socket}
image ->
{:ok, updated} = Media.update_image_metadata(image, %{alt: alt})
# Refresh the relevant assign so the template sees the new alt text
socket =
cond do
socket.assigns.logo_image && socket.assigns.logo_image.id == image_id ->
assign(socket, :logo_image, updated)
socket.assigns.header_image && socket.assigns.header_image.id == image_id ->
assign(socket, :header_image, updated)
socket.assigns[:icon_image] && socket.assigns.icon_image &&
socket.assigns.icon_image.id == image_id ->
assign(socket, :icon_image, updated)
true ->
socket
end
{:noreply, socket}
end
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",
header_nav_items: BerrypodWeb.ThemeHook.default_header_nav(),
footer_nav_items: BerrypodWeb.ThemeHook.default_footer_nav()
})
end
# Unified preview — loads page definition, applies context, renders via PageRenderer
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(assigns) do
slug = to_string(assigns.page)
page = Pages.get_page(slug)
assigns =
assigns
|> preview_assigns()
|> assign(:page, page)
|> preview_page_context(slug)
extra = Pages.load_block_data(page.blocks, assigns)
assigns = assign(assigns, extra)
~H"<BerrypodWeb.PageRenderer.render_page {assigns} />"
end
# Page-context data needed by specific page types in preview mode
defp preview_page_context(assigns, "pdp") 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
|> assign(:product, product)
|> assign(:gallery_images, build_gallery_images(product))
|> assign(:option_types, option_types)
|> assign(:selected_options, selected_options)
|> assign(:available_options, available_options)
|> assign(:display_price, display_price)
|> assign(:quantity, 1)
|> assign(:option_urls, %{})
end
defp preview_page_context(assigns, "cart") 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
|> assign(:cart_page_items, cart_items)
|> assign(:cart_page_subtotal, subtotal)
end
defp preview_page_context(assigns, "about") do
assign(assigns, :content_blocks, PreviewData.about_content())
end
defp preview_page_context(assigns, "delivery") do
assign(assigns, :content_blocks, PreviewData.delivery_content())
end
defp preview_page_context(assigns, "privacy") do
assign(assigns, :content_blocks, PreviewData.privacy_content())
end
defp preview_page_context(assigns, "terms") do
assign(assigns, :content_blocks, PreviewData.terms_content())
end
defp preview_page_context(assigns, "error") do
assign(assigns, %{
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."
})
end
defp preview_page_context(assigns, _slug), do: assigns
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