Some checks failed
deploy / deploy (push) Has been cancelled
All 14 pages now render through PageRenderer. Theme editor preview unified from 10 preview_page clauses to one function + page-context helpers. PageTemplates module and 10 .heex template files deleted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
515 lines
14 KiB
Elixir
515 lines
14 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("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
|
|
|
|
# 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
|