redirect /admin/theme to on-site editor at /?edit=theme
All checks were successful
deploy / deploy (push) Successful in 1m41s
All checks were successful
deploy / deploy (push) Successful in 1m41s
- Phase 5 was already implemented (URL mode activation via ?edit param) - Phase 6: Add RedirectController to redirect /admin/theme → /?edit=theme - Update admin sidebar and dashboard links to point directly to /?edit=theme - Delete old Admin.Theme.Index LiveView and template (no longer needed) - Update tests for new redirect behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -143,10 +143,7 @@
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/admin/theme"}
|
||||
class={admin_nav_active?(@current_path, "/admin/theme")}
|
||||
>
|
||||
<.link href="/?edit=theme">
|
||||
<.icon name="hero-paint-brush" class="size-5" /> Theme
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
defmodule BerrypodWeb.ShopComponents.ThemeEditor do
|
||||
@moduledoc """
|
||||
Shared theme editor components used in both:
|
||||
- Admin theme page (`/admin/theme`)
|
||||
- On-site editor panel (page editor Theme tab)
|
||||
Theme editor components for the on-site editor panel (page editor Theme tab).
|
||||
|
||||
Components render settings controls that emit standard events:
|
||||
- `update_setting` / `theme_update_setting` (phx-click/phx-change)
|
||||
|
||||
10
lib/berrypod_web/controllers/redirect_controller.ex
Normal file
10
lib/berrypod_web/controllers/redirect_controller.ex
Normal file
@@ -0,0 +1,10 @@
|
||||
defmodule BerrypodWeb.RedirectController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
@doc """
|
||||
Redirects /admin/theme to the on-site theme editor.
|
||||
"""
|
||||
def theme(conn, _params) do
|
||||
redirect(conn, to: "/?edit=theme")
|
||||
end
|
||||
end
|
||||
@@ -58,7 +58,7 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
<.link href={~p"/"} class="admin-btn admin-btn-primary">
|
||||
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
|
||||
</.link>
|
||||
<.link navigate={~p"/admin/theme"} class="admin-btn admin-btn-secondary">
|
||||
<.link href="/?edit=theme" class="admin-btn admin-btn-secondary">
|
||||
<.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme
|
||||
</.link>
|
||||
</div>
|
||||
@@ -273,7 +273,7 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
%{
|
||||
key: :theme_customised,
|
||||
label: "Customise your theme",
|
||||
href: "/admin/theme?from=checklist",
|
||||
href: "/?edit=theme",
|
||||
hint: "Upload your logo, pick your colours, and choose a font that matches your brand."
|
||||
},
|
||||
%{
|
||||
|
||||
@@ -1,630 +0,0 @@
|
||||
defmodule BerrypodWeb.Admin.Theme.Index do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.{Pages, Settings}
|
||||
alias Berrypod.Media
|
||||
alias Berrypod.Theme.{Contrast, 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(:site_name, Settings.site_name())
|
||||
|> assign(:site_description, Settings.site_description())
|
||||
|> 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)
|
||||
|> compute_header_contrast_warning()
|
||||
|> 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, assign(socket, :from_checklist, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
{:noreply, assign(socket, :from_checklist, params["from"] == "checklist")}
|
||||
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 | _] ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:header_image, image)
|
||||
|> compute_header_contrast_warning()
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
_ ->
|
||||
{: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)
|
||||
|> compute_header_contrast_warning()
|
||||
|> 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
|
||||
|
||||
# Settings stored outside the theme JSON
|
||||
@standalone_settings ~w(site_name site_description)
|
||||
|
||||
@impl true
|
||||
def handle_event("update_setting", %{"field" => field, "setting_value" => value}, socket)
|
||||
when field in @standalone_settings do
|
||||
Settings.put_setting(field, value, "string")
|
||||
{:noreply, assign(socket, String.to_existing_atom(field), value)}
|
||||
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)
|
||||
|> maybe_recompute_contrast_warning(field)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_setting", %{"field" => field} = params, socket)
|
||||
when field in @standalone_settings do
|
||||
value = params[field]
|
||||
|
||||
if value do
|
||||
Settings.put_setting(field, value, "string")
|
||||
{:noreply, assign(socket, String.to_existing_atom(field), value)}
|
||||
else
|
||||
{: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)
|
||||
|> maybe_recompute_contrast_warning(field)
|
||||
|
||||
{: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)
|
||||
new_value = !current_value
|
||||
|
||||
# Prevent turning off show_site_name when there's no logo to display
|
||||
if field_atom == :show_site_name && new_value == false && !has_valid_logo?(socket) do
|
||||
{:noreply, put_flash(socket, :error, "Upload a logo first to hide the shop name")}
|
||||
else
|
||||
attrs = %{field_atom => new_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)
|
||||
|> maybe_recompute_contrast_warning(field)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
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
|
||||
|
||||
# Re-enable shop name when removing logo to ensure header isn't empty
|
||||
{:ok, theme_settings} =
|
||||
Settings.update_theme_settings(%{logo_image_id: nil, show_site_name: true})
|
||||
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:logo_image, nil)
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> 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)
|
||||
|> assign(:header_contrast_warning, :ok)
|
||||
|> 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
|
||||
|
||||
defp has_valid_logo?(socket) do
|
||||
socket.assigns.theme_settings.show_logo && socket.assigns.logo_image != nil
|
||||
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 :site_name, :string, 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
|
||||
|
||||
# Compute header contrast warning based on image colors and theme mood
|
||||
defp compute_header_contrast_warning(socket) do
|
||||
header_image = socket.assigns.header_image
|
||||
theme_settings = socket.assigns.theme_settings
|
||||
|
||||
warning =
|
||||
if theme_settings.header_background_enabled && header_image do
|
||||
text_color = Contrast.text_color_for_mood(theme_settings.mood)
|
||||
colors = Contrast.parse_dominant_colors(header_image.dominant_colors)
|
||||
Contrast.analyze_header_contrast(colors, text_color)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
|
||||
assign(socket, :header_contrast_warning, warning)
|
||||
end
|
||||
|
||||
# Only recompute when mood or header_background_enabled changes
|
||||
defp maybe_recompute_contrast_warning(socket, field)
|
||||
when field in ["mood", "header_background_enabled"] do
|
||||
compute_header_contrast_warning(socket)
|
||||
end
|
||||
|
||||
defp maybe_recompute_contrast_warning(socket, _field), do: socket
|
||||
end
|
||||
@@ -1,651 +0,0 @@
|
||||
<div class="theme-layout">
|
||||
<!-- Controls Sidebar -->
|
||||
<div
|
||||
id="theme-sidebar"
|
||||
class={[
|
||||
"theme-sidebar",
|
||||
if(@sidebar_collapsed,
|
||||
do: "theme-sidebar-collapsed",
|
||||
else: "theme-sidebar-expanded"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<!-- Collapsed state: just show expand button -->
|
||||
<%= if @sidebar_collapsed do %>
|
||||
<div class="theme-sidebar-collapsed-inner">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_sidebar"
|
||||
class="theme-collapse-btn"
|
||||
aria-label="Expand sidebar"
|
||||
aria-expanded="false"
|
||||
aria-controls="theme-sidebar"
|
||||
>
|
||||
<svg
|
||||
class="theme-collapse-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<.link href={~p"/admin"} class="theme-back-link">
|
||||
<.icon name="hero-arrow-left-mini" class="size-4" /> Admin
|
||||
</.link>
|
||||
|
||||
<div :if={@from_checklist} class="admin-checklist-banner">
|
||||
<.icon name="hero-clipboard-document-check" class="size-5 admin-checklist-banner-icon" />
|
||||
<span class="admin-checklist-banner-text">
|
||||
You're customising your theme.
|
||||
</span>
|
||||
<.link navigate={~p"/admin"} class="admin-link admin-checklist-banner-link">
|
||||
← Back to checklist
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="theme-header">
|
||||
<div class="admin-fill">
|
||||
<h1 class="theme-title">Theme</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_sidebar"
|
||||
class="theme-collapse-btn"
|
||||
aria-label="Collapse sidebar"
|
||||
aria-expanded="true"
|
||||
aria-controls="theme-sidebar"
|
||||
>
|
||||
<svg
|
||||
class="theme-collapse-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Site Name -->
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Shop name</label>
|
||||
<form phx-change="update_setting" phx-value-field="site_name">
|
||||
<input
|
||||
type="text"
|
||||
name="site_name"
|
||||
value={@site_name}
|
||||
placeholder="Your shop name"
|
||||
class="admin-input admin-input-lg"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Branding Section -->
|
||||
<div class="theme-panel">
|
||||
<span class="theme-section-label">Logo & header</span>
|
||||
|
||||
<div class="admin-stack admin-stack-sm theme-field">
|
||||
<label class="admin-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.show_site_name}
|
||||
phx-click="toggle_setting"
|
||||
phx-value-field="show_site_name"
|
||||
class="admin-toggle admin-toggle-sm"
|
||||
/>
|
||||
<span class="theme-slider-label">Show shop name</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.show_logo}
|
||||
phx-click="toggle_setting"
|
||||
phx-value-field="show_logo"
|
||||
class="admin-toggle admin-toggle-sm"
|
||||
/>
|
||||
<span class="theme-slider-label">Show logo</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Logo Upload (when logo enabled) -->
|
||||
<%= if @theme_settings.show_logo do %>
|
||||
<div class="theme-subsection">
|
||||
<span class="theme-slider-label theme-block-label">
|
||||
Upload logo (SVG or PNG)
|
||||
</span>
|
||||
<div class="admin-row admin-row-lg">
|
||||
<form phx-change="noop" phx-submit="noop" class="admin-fill">
|
||||
<label class="theme-upload-label">
|
||||
<span>Choose file...</span>
|
||||
<.live_file_input upload={@uploads.logo_upload} class="hidden" />
|
||||
</label>
|
||||
</form>
|
||||
<%= if @logo_image do %>
|
||||
<div class="theme-thumb theme-thumb-logo">
|
||||
<img
|
||||
src={"/image_cache/#{@logo_image.id}.webp"}
|
||||
alt={@site_name}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_logo"
|
||||
class="theme-remove-btn"
|
||||
title="Remove logo"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= for entry <- @uploads.logo_upload.entries do %>
|
||||
<div class="theme-progress">
|
||||
<div class="theme-progress-bar">
|
||||
<div
|
||||
class="theme-progress-fill"
|
||||
style={"width: #{entry.progress}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span class="admin-text-secondary">{entry.progress}%</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
phx-value-upload="logo_upload"
|
||||
class="theme-upload-cancel"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<%= for err <- upload_errors(@uploads.logo_upload, entry) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= for err <- upload_errors(@uploads.logo_upload) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
|
||||
<!-- Logo Size Slider -->
|
||||
<%= if @logo_image do %>
|
||||
<form
|
||||
phx-change="update_setting"
|
||||
phx-value-field="logo_size"
|
||||
class="theme-subfield"
|
||||
>
|
||||
<div class="theme-slider-header">
|
||||
<span class="theme-slider-label">Logo size</span>
|
||||
<span class="theme-slider-value">{@theme_settings.logo_size}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="24"
|
||||
max="120"
|
||||
value={@theme_settings.logo_size}
|
||||
name="logo_size"
|
||||
class="admin-range"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<!-- SVG Recolor Toggle (only for SVG logos) -->
|
||||
<%= if @logo_image.is_svg do %>
|
||||
<div class="theme-subfield">
|
||||
<label class="admin-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.logo_recolor}
|
||||
phx-click="update_setting"
|
||||
phx-value-field="logo_recolor"
|
||||
phx-value-setting_value={
|
||||
if @theme_settings.logo_recolor, do: "false", else: "true"
|
||||
}
|
||||
class="admin-toggle admin-toggle-sm"
|
||||
/>
|
||||
<span class="theme-slider-label">Recolour logo</span>
|
||||
</label>
|
||||
|
||||
<%= if @theme_settings.logo_recolor do %>
|
||||
<form
|
||||
id="logo-color-form"
|
||||
phx-change="update_color"
|
||||
phx-value-field="logo_color"
|
||||
phx-hook="ColorSync"
|
||||
class="theme-color-row theme-subfield-sm"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
name="value"
|
||||
value={@theme_settings.logo_color}
|
||||
class="theme-color-swatch theme-color-swatch-sm"
|
||||
/>
|
||||
<span class="theme-color-value">{@theme_settings.logo_color}</span>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Site Icon / Favicon -->
|
||||
<div class="theme-panel">
|
||||
<label class="theme-section-label">Site icon</label>
|
||||
<p class="admin-text-tertiary theme-field">
|
||||
Your icon appears in browser tabs and on home screens.
|
||||
</p>
|
||||
|
||||
<!-- Use logo as icon toggle -->
|
||||
<label class="admin-toggle-label theme-field">
|
||||
<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="theme-slider-label">Use logo as favicon</span>
|
||||
</label>
|
||||
|
||||
<!-- Icon upload (only when not using logo) -->
|
||||
<%= if !@theme_settings.use_logo_as_icon do %>
|
||||
<div class="admin-separator">
|
||||
<span class="theme-slider-label theme-block-label">
|
||||
Upload icon (PNG or SVG, 512×512+)
|
||||
</span>
|
||||
<div class="admin-row admin-row-lg">
|
||||
<form phx-change="noop" phx-submit="noop" class="admin-fill">
|
||||
<label class="theme-upload-label">
|
||||
<span>Choose file...</span>
|
||||
<.live_file_input upload={@uploads.icon_upload} class="hidden" />
|
||||
</label>
|
||||
</form>
|
||||
<%= if @icon_image do %>
|
||||
<div class="theme-thumb theme-thumb-icon">
|
||||
<%= if @icon_image.is_svg do %>
|
||||
<img
|
||||
src={"/images/#{@icon_image.id}/recolored/000000"}
|
||||
alt="Current icon"
|
||||
/>
|
||||
<% else %>
|
||||
<img
|
||||
src={"/image_cache/#{@icon_image.id}.webp"}
|
||||
alt="Current icon"
|
||||
/>
|
||||
<% end %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_icon"
|
||||
class="theme-remove-btn"
|
||||
title="Remove icon"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= for entry <- @uploads.icon_upload.entries do %>
|
||||
<div class="theme-progress">
|
||||
<div class="theme-progress-bar">
|
||||
<div
|
||||
class="theme-progress-fill"
|
||||
style={"width: #{entry.progress}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span class="admin-text-secondary">{entry.progress}%</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
phx-value-upload="icon_upload"
|
||||
class="theme-upload-cancel"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<%= for err <- upload_errors(@uploads.icon_upload, entry) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= for err <- upload_errors(@uploads.icon_upload) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Short name -->
|
||||
<div class="theme-subfield">
|
||||
<form phx-change="update_setting" phx-value-field="favicon_short_name">
|
||||
<label class="theme-slider-label theme-block-label">
|
||||
Short name <span class="admin-text-tertiary">— appears under home screen icon</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="favicon_short_name"
|
||||
value={@theme_settings.favicon_short_name}
|
||||
placeholder={String.slice(@site_name, 0, 12)}
|
||||
maxlength="12"
|
||||
class="admin-input admin-input-sm"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Icon background colour -->
|
||||
<div class="theme-subfield">
|
||||
<form
|
||||
id="icon-bg-color-form"
|
||||
phx-change="update_color"
|
||||
phx-value-field="icon_background_color"
|
||||
phx-hook="ColorSync"
|
||||
class="theme-color-row"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
name="value"
|
||||
value={@theme_settings.icon_background_color}
|
||||
class="theme-color-swatch theme-color-swatch-sm"
|
||||
/>
|
||||
<div>
|
||||
<span class="theme-slider-label theme-block-label">Icon background</span>
|
||||
<span class="theme-slider-value">
|
||||
{@theme_settings.icon_background_color}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Background Toggle -->
|
||||
<div class="theme-section">
|
||||
<label class="admin-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.header_background_enabled}
|
||||
phx-click="update_setting"
|
||||
phx-value-field="header_background_enabled"
|
||||
phx-value-setting_value={
|
||||
if @theme_settings.header_background_enabled, do: "false", else: "true"
|
||||
}
|
||||
class="admin-toggle admin-toggle-sm"
|
||||
/>
|
||||
<span class="theme-check-text">
|
||||
Header background image
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Header Image Upload (only when enabled) -->
|
||||
<%= if @theme_settings.header_background_enabled do %>
|
||||
<div class="theme-panel">
|
||||
<span class="theme-slider-label theme-block-label">
|
||||
Upload header image
|
||||
</span>
|
||||
<form phx-change="noop" phx-submit="noop">
|
||||
<label class="theme-upload-label">
|
||||
<span>Choose file...</span>
|
||||
<.live_file_input upload={@uploads.header_upload} class="hidden" />
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<%= if @header_image do %>
|
||||
<div class="theme-thumb theme-thumb-cover theme-thumb-header">
|
||||
<img
|
||||
src={"/image_cache/#{@header_image.id}.webp"}
|
||||
alt=""
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_header"
|
||||
class="theme-remove-btn"
|
||||
title="Remove header background"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @header_contrast_warning != :ok do %>
|
||||
<div class="theme-contrast-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<div>
|
||||
<strong>
|
||||
<%= if @header_contrast_warning == :poor do %>
|
||||
Text may be hard to read
|
||||
<% else %>
|
||||
Text contrast could be better
|
||||
<% end %>
|
||||
</strong>
|
||||
<p>
|
||||
The header text might blend into this background.
|
||||
Try switching to a
|
||||
<%= if @theme_settings.mood == "dark" do %>
|
||||
lighter mood
|
||||
<% else %>
|
||||
dark mood
|
||||
<% end %>
|
||||
or choosing a different image.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Header Image Controls -->
|
||||
<div class="admin-stack admin-stack-md theme-subfield">
|
||||
<form phx-change="update_setting" phx-value-field="header_zoom">
|
||||
<div class="theme-slider-header">
|
||||
<span class="theme-slider-label">Zoom</span>
|
||||
<span class="theme-slider-value">{@theme_settings.header_zoom}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="100"
|
||||
max="200"
|
||||
value={@theme_settings.header_zoom}
|
||||
name="header_zoom"
|
||||
class="admin-range"
|
||||
/>
|
||||
</form>
|
||||
<%= if @theme_settings.header_zoom > 100 do %>
|
||||
<form phx-change="update_setting" phx-value-field="header_position_x">
|
||||
<div class="theme-slider-header">
|
||||
<span class="theme-slider-label">Horizontal position</span>
|
||||
<span class="theme-slider-value">{@theme_settings.header_position_x}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={@theme_settings.header_position_x}
|
||||
name="header_position_x"
|
||||
class="admin-range"
|
||||
/>
|
||||
</form>
|
||||
<form phx-change="update_setting" phx-value-field="header_position_y">
|
||||
<div class="theme-slider-header">
|
||||
<span class="theme-slider-label">Vertical position</span>
|
||||
<span class="theme-slider-value">{@theme_settings.header_position_y}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={@theme_settings.header_position_y}
|
||||
name="header_position_y"
|
||||
class="admin-range"
|
||||
/>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= for entry <- @uploads.header_upload.entries do %>
|
||||
<div class="theme-progress">
|
||||
<div class="theme-progress-bar">
|
||||
<div
|
||||
class="theme-progress-fill"
|
||||
style={"width: #{entry.progress}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span class="admin-text-secondary">{entry.progress}%</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
phx-value-upload="header_upload"
|
||||
class="theme-upload-cancel"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<%= for err <- upload_errors(@uploads.header_upload, entry) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= for err <- upload_errors(@uploads.header_upload) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Presets Section -->
|
||||
<.preset_grid
|
||||
presets={@presets_with_descriptions}
|
||||
active_preset={@active_preset}
|
||||
event_prefix=""
|
||||
label="Start with a preset"
|
||||
/>
|
||||
|
||||
<!-- Accent Colors -->
|
||||
<.color_picker
|
||||
field="accent_color"
|
||||
label="Accent colour"
|
||||
value={@theme_settings.accent_color}
|
||||
event_prefix=""
|
||||
/>
|
||||
<.color_picker
|
||||
field="secondary_accent_color"
|
||||
label="Hover colour"
|
||||
value={@theme_settings.secondary_accent_color}
|
||||
event_prefix=""
|
||||
/>
|
||||
<.color_picker
|
||||
field="sale_color"
|
||||
label="Sale colour"
|
||||
value={@theme_settings.sale_color}
|
||||
event_prefix=""
|
||||
/>
|
||||
|
||||
<!-- Customise Section -->
|
||||
<.customise_accordion
|
||||
theme_settings={@theme_settings}
|
||||
customise_open={@customise_open}
|
||||
event_prefix=""
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Preview Area -->
|
||||
<div class="theme-preview-area">
|
||||
<div class="theme-preview-container">
|
||||
<!-- Preview Page Switcher -->
|
||||
<div class="theme-preview-tabs">
|
||||
<%= for {page_name, label} <- [
|
||||
{:home, "Home"},
|
||||
{:collection, "Collection"},
|
||||
{:pdp, "Product"},
|
||||
{:cart, "Cart"},
|
||||
{:about, "About"},
|
||||
{:delivery, "Delivery"},
|
||||
{:privacy, "Privacy"},
|
||||
{:terms, "Terms"},
|
||||
{:contact, "Contact"},
|
||||
{:error, "404"}
|
||||
] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page={page_name}
|
||||
class={[
|
||||
"theme-preview-tab",
|
||||
@preview_page == page_name && "theme-preview-tab-active"
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Browser Chrome -->
|
||||
<div class="theme-browser-chrome">
|
||||
<div class="theme-browser-dots">
|
||||
<div class="theme-browser-dot theme-browser-dot-close"></div>
|
||||
<div class="theme-browser-dot theme-browser-dot-min"></div>
|
||||
<div class="theme-browser-dot theme-browser-dot-max"></div>
|
||||
</div>
|
||||
<div class="theme-browser-url">
|
||||
<svg
|
||||
class="theme-browser-url-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
<span class="theme-browser-url-text truncate">
|
||||
{@site_name |> String.downcase() |> String.replace(" ", "")}.myshopify.com
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Frame -->
|
||||
<div
|
||||
class="themed theme-preview-frame"
|
||||
data-mood={@theme_settings.mood}
|
||||
data-typography={@theme_settings.typography}
|
||||
data-shape={@theme_settings.shape}
|
||||
data-density={@theme_settings.density}
|
||||
data-grid={@theme_settings.grid_columns}
|
||||
data-header={@theme_settings.header_layout}
|
||||
data-sticky={to_string(@theme_settings.sticky_header)}
|
||||
data-layout={@theme_settings.layout_width}
|
||||
data-shadow={@theme_settings.card_shadow}
|
||||
data-button-style={@theme_settings.button_style}
|
||||
>
|
||||
<style>
|
||||
/* All font faces for theme switching */
|
||||
<%= Phoenix.HTML.raw(Berrypod.Theme.Fonts.generate_all_font_faces(
|
||||
&BerrypodWeb.Endpoint.static_path/1
|
||||
)) %>
|
||||
/* Generated theme CSS */
|
||||
<%= Phoenix.HTML.raw(@generated_css) %>
|
||||
</style>
|
||||
|
||||
<.preview_page
|
||||
page={@preview_page}
|
||||
preview_data={@preview_data}
|
||||
theme_settings={@theme_settings}
|
||||
site_name={@site_name}
|
||||
logo_image={@logo_image}
|
||||
header_image={@header_image}
|
||||
cart_drawer_open={@cart_drawer_open}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,11 +183,8 @@ defmodule BerrypodWeb.Router do
|
||||
live "/redirects", Admin.Redirects, :index
|
||||
end
|
||||
|
||||
# Theme editor: admin root layout but full-screen (no sidebar)
|
||||
live_session :admin_theme,
|
||||
on_mount: [{BerrypodWeb.UserAuth, :require_authenticated}] do
|
||||
live "/theme", Admin.Theme.Index, :index
|
||||
end
|
||||
# Theme editor redirects to on-site editing
|
||||
get "/theme", RedirectController, :theme
|
||||
end
|
||||
|
||||
# User account settings
|
||||
|
||||
Reference in New Issue
Block a user