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

@ -92,8 +92,8 @@ Extend the existing page editor (PageEditorHook + editor_sheet) to include theme
| 3 | Extract theme editor into reusable component | 3h | done |
| 3b | Create settings editor component | 2h | done |
| 4 | Image upload handling in hook context | 2h | done |
| 5 | URL-based mode activation (?edit=theme) | 1h | planned |
| 6 | Admin routing redirect | 30m | planned |
| 5 | URL-based mode activation (?edit=theme) | 1h | done |
| 6 | Admin routing redirect | 30m | done |
| 7 | Polish and testing | 2h | planned |
### Quick fixes (from usability testing)

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

View File

@ -19,7 +19,7 @@ defmodule BerrypodWeb.Admin.LayoutTest do
assert has_element?(view, ~s(a[href="/admin"]), "Dashboard")
assert has_element?(view, ~s(a[href="/admin/orders"]), "Orders")
assert has_element?(view, ~s(a[href="/admin/theme"]), "Theme")
assert has_element?(view, ~s(a[href="/?edit=theme"]), "Theme")
assert has_element?(view, ~s(a[href="/admin/settings"]), "Settings")
end
@ -45,24 +45,6 @@ defmodule BerrypodWeb.Admin.LayoutTest do
end
end
describe "theme editor layout" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "does not render sidebar", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/theme")
refute html =~ "admin-drawer"
end
test "shows back link to admin", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
assert has_element?(view, ~s(a[href="/admin"]), "Admin")
end
end
describe "admin bar on shop pages" do
setup do
{:ok, _} = Berrypod.Settings.set_site_live(true)

View File

@ -1,216 +1,24 @@
defmodule BerrypodWeb.Admin.ThemeTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
alias Berrypod.Settings
setup do
user = user_fixture()
%{user: user}
end
describe "Index (unauthenticated)" do
test "redirects to login when not authenticated", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin/theme")
describe "/admin/theme redirect" do
test "redirects unauthenticated users to login", %{conn: conn} do
conn = get(conn, ~p"/admin/theme")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Index (authenticated)" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
assert redirected_to(conn) == ~p"/users/log-in"
end
test "renders theme editor page", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/theme")
test "redirects authenticated users to on-site editor", %{conn: conn, user: user} do
conn = conn |> log_in_user(user) |> get(~p"/admin/theme")
assert html =~ "<h1 class=\"theme-title\">Theme</h1>"
assert html =~ "preset"
end
test "displays all 8 presets", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/theme")
assert html =~ "gallery"
assert html =~ "studio"
assert html =~ "boutique"
assert html =~ "bold"
assert html =~ "playful"
assert html =~ "minimal"
assert html =~ "night"
assert html =~ "classic"
end
test "displays current theme settings", %{conn: conn} do
{:ok, _settings} = Settings.apply_preset(:gallery)
{:ok, _view, html} = live(conn, ~p"/admin/theme")
assert html =~ "warm"
assert html =~ "editorial"
assert html =~ "soft"
assert html =~ "spacious"
end
test "displays generated CSS in preview", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/theme")
# CSS generator outputs accent colors and layout variables for shop pages
assert html =~ ".themed {"
assert html =~ "--t-accent-h:"
end
test "applies preset and updates preview", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
html =
view
|> element("button", "gallery")
|> render_click()
theme_settings = Settings.get_theme_settings()
assert theme_settings.mood == "warm"
assert theme_settings.typography == "editorial"
assert html =~ "warm"
assert html =~ "editorial"
end
test "switches preview page", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
html =
view
|> element("button", "Collection")
|> render_click()
assert html =~ "All Products"
end
test "theme settings are saved when applying a preset", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Apply a preset
view
|> element("button", "gallery")
|> render_click()
# Verify settings were persisted
theme_settings = Settings.get_theme_settings()
assert theme_settings.mood == "warm"
assert theme_settings.typography == "editorial"
end
test "all preview page buttons are present", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/theme")
assert html =~ "Home"
assert html =~ "Collection"
assert html =~ "Product"
assert html =~ "Cart"
assert html =~ "About"
assert html =~ "Contact"
assert html =~ "404"
end
test "mood customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "dark" mood button
html =
view
|> element("button[phx-value-setting_value='dark']")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.mood == "dark"
assert html =~ "dark"
end
test "shape customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "round" shape button
view
|> element("button[phx-value-setting_value='round']")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.shape == "round"
end
test "density customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "compact" density button
view
|> element("button[phx-value-setting_value='compact']")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.density == "compact"
end
test "grid columns customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "2 columns" grid columns button
view
|> element("button", "2 columns")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.grid_columns == "2"
end
test "typography customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "modern" typography button
view
|> element("button[phx-value-setting_value='modern']")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.typography == "modern"
end
test "header layout customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "centered" header layout button
view
|> element("button[phx-value-setting_value='centered']")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.header_layout == "centered"
end
test "CSS regenerates when settings change", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/admin/theme")
# Capture initial CSS
initial_css = html
# Change a setting
new_html =
view
|> element("button[phx-value-setting_value='dark']")
|> render_click()
# Verify CSS has changed
refute initial_css == new_html
assert new_html =~ "--t-accent-h:"
assert redirected_to(conn) == "/?edit=theme"
end
end
end

View File

@ -1,12 +1,11 @@
defmodule BerrypodWeb.ThemeCSSConsistencyTest do
@moduledoc """
Tests that verify CSS works correctly for both the theme editor
preview and the shop pages using the shared .themed class.
Tests that verify CSS works correctly for shop pages using the .themed class.
Architecture:
- Both shop pages and preview use .themed class for shared styles
- Theme editor uses .preview-frame[data-*] selectors for live switching (in admin.css)
- Shop pages get theme values via inline CSS from CSSGenerator (shop.css)
- Shop pages use .themed class for theme-aware styles
- Theme editor is on-site (/?edit=theme) so it uses the same CSS as shop pages
- Shop pages get theme values via inline CSS from CSSGenerator
- Component styles use .themed for shared styling (theme-layer2-attributes.css)
"""
@ -34,11 +33,12 @@ defmodule BerrypodWeb.ThemeCSSConsistencyTest do
assert html =~ ~r/data-density="/
end
test "theme editor has .themed with data attributes", %{conn: conn, user: user} do
test "on-site theme editor has .themed with data attributes", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/admin/theme")
# Theme editor is now on-site at /?edit=theme
{:ok, _view, html} = live(conn, ~p"/?edit=theme")
# Verify themed element exists in preview-frame with theme data attributes
# Verify themed element exists with theme data attributes
assert html =~ ~r/<div[^>]*class="themed/
assert html =~ ~r/data-mood="/
assert html =~ ~r/data-typography="/
@ -46,26 +46,6 @@ defmodule BerrypodWeb.ThemeCSSConsistencyTest do
assert html =~ ~r/data-density="/
end
test "shop page uses same theme settings as preview", %{conn: conn, user: user} do
# Set a specific theme configuration
{:ok, _settings} = Settings.apply_preset(:night)
# Check shop page (logged in since site_live is false by default)
conn = log_in_user(conn, user)
{:ok, _view, shop_html} = live(conn, ~p"/")
# Check preview (already authenticated)
{:ok, _view, preview_html} = live(conn, ~p"/admin/theme")
# Extract data-mood values from both
[_, shop_mood] = Regex.run(~r/data-mood="([^"]+)"/, shop_html)
[_, preview_mood] = Regex.run(~r/data-mood="([^"]+)"/, preview_html)
# They should match
assert shop_mood == preview_mood
assert shop_mood == "dark"
end
test "theme settings changes are reflected on shop page", %{conn: conn, user: user} do
conn = log_in_user(conn, user)