redirect /admin/theme to on-site editor at /?edit=theme
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:
jamey
2026-03-09 20:30:33 +00:00
parent 378b3fdb6b
commit 6f0b7f4f63
11 changed files with 34 additions and 1543 deletions

View File

@@ -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>

View File

@@ -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)

View 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

View File

@@ -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."
},
%{

View File

@@ -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

View File

@@ -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">
&larr; 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>

View File

@@ -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