implement unified on-site editor phases 1-2
All checks were successful
deploy / deploy (push) Successful in 1m10s
All checks were successful
deploy / deploy (push) Successful in 1m10s
Add theme editing to the existing PageEditorHook, enabling on-site theme customisation alongside page editing. The editor panel now has three tabs (Page, Theme, Settings) and can be collapsed while keeping editing state intact. - Add theme editing state and event handlers to PageEditorHook - Add 3-tab UI with tab switching logic - Add transparent overlay for click-outside dismiss - Add mobile drag-to-resize with height persistence - Fix animation replay on drag release (has-dragged class) - Preserve panel height across LiveView re-renders - Default to Page tab on editable pages, Theme otherwise - Show unsaved changes indicator on FAB when panel collapsed - Fix handle_event grouping warning in admin theme Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,10 +48,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
|
||||
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates
|
||||
# can spread assigns without listing each one explicitly.
|
||||
@layout_keys ~w(theme_settings site_name logo_image header_image mode cart_items cart_count
|
||||
@layout_keys ~w(theme_settings generated_css site_name logo_image header_image mode cart_items cart_count
|
||||
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
|
||||
search_query search_results search_open categories shipping_estimate
|
||||
country_code available_countries editing editor_current_path editor_sidebar_open
|
||||
country_code available_countries editing theme_editing editor_current_path editor_sidebar_open
|
||||
editor_active_tab editor_sheet_state editor_dirty editor_save_status
|
||||
header_nav_items footer_nav_items newsletter_enabled newsletter_state stripe_connected)a
|
||||
|
||||
@doc """
|
||||
@@ -102,6 +103,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
attr :newsletter_enabled, :boolean, default: false
|
||||
attr :newsletter_state, :atom, default: :idle
|
||||
attr :stripe_connected, :boolean, default: true
|
||||
attr :generated_css, :string, default: nil
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
@@ -110,9 +112,24 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
<div
|
||||
id={unless @error_page, do: "shop-container"}
|
||||
phx-hook={unless @error_page, do: "CartPersist"}
|
||||
class="shop-container"
|
||||
class="shop-container themed"
|
||||
data-bottom-nav={!@error_page || nil}
|
||||
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}
|
||||
>
|
||||
<%!-- Live-updatable theme CSS (overrides static version in head) --%>
|
||||
<%= if @generated_css do %>
|
||||
{Phoenix.HTML.raw("<style id=\"theme-css-live\">#{@generated_css}</style>")}
|
||||
<% end %>
|
||||
|
||||
<.skip_link />
|
||||
|
||||
<%= if @theme_settings.announcement_bar do %>
|
||||
@@ -1032,7 +1049,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
# ── Editor sheet ────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Renders the unified editor sheet for page editing.
|
||||
Renders the unified editor sheet for page/theme/settings editing.
|
||||
|
||||
The sheet is anchored to the bottom edge on mobile (<768px) and the right edge
|
||||
on desktop (≥768px). It has three states on mobile (collapsed, partial, full)
|
||||
@@ -1040,59 +1057,98 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
|
||||
## Attributes
|
||||
|
||||
* `editing` - Whether edit mode is active.
|
||||
* `editor_dirty` - Whether there are unsaved changes.
|
||||
* `editing` - Whether page edit mode is active.
|
||||
* `theme_editing` - Whether theme edit mode is active.
|
||||
* `editor_dirty` - Whether there are unsaved page changes.
|
||||
* `editor_sheet_state` - Current state (:collapsed, :partial, :full, or :open).
|
||||
* `editor_active_tab` - Current tab (:page, :theme, :settings).
|
||||
* `has_editable_page` - Whether the current page has editable blocks.
|
||||
|
||||
## Slots
|
||||
|
||||
* `inner_block` - The editor content (block list, settings, etc.).
|
||||
"""
|
||||
attr :editing, :boolean, default: false
|
||||
attr :theme_editing, :boolean, default: false
|
||||
attr :editor_dirty, :boolean, default: false
|
||||
attr :editor_sheet_state, :atom, default: :collapsed
|
||||
attr :editor_save_status, :atom, default: :idle
|
||||
attr :editor_active_tab, :atom, default: :page
|
||||
attr :has_editable_page, :boolean, default: false
|
||||
|
||||
slot :inner_block
|
||||
|
||||
def editor_sheet(assigns) do
|
||||
# Determine panel title based on active tab
|
||||
title =
|
||||
case assigns.editor_active_tab do
|
||||
:page -> "Page"
|
||||
:theme -> "Theme"
|
||||
:settings -> "Settings"
|
||||
end
|
||||
|
||||
# Any editing mode active
|
||||
any_editing = assigns.editing || assigns.theme_editing
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:title, title)
|
||||
|> assign(:any_editing, any_editing)
|
||||
|
||||
~H"""
|
||||
<%!-- Floating action button: always visible when panel is closed --%>
|
||||
<button
|
||||
:if={@editor_sheet_state == :collapsed}
|
||||
type="button"
|
||||
phx-click={if @editing, do: "editor_set_sheet_state", else: "editor_toggle_editing"}
|
||||
phx-value-state={if @editing, do: "open", else: nil}
|
||||
phx-click={if @any_editing, do: "editor_set_sheet_state", else: "editor_set_tab"}
|
||||
phx-value-state={if @any_editing, do: "open", else: nil}
|
||||
phx-value-tab={
|
||||
if @any_editing, do: nil, else: if(@has_editable_page, do: "page", else: "theme")
|
||||
}
|
||||
class="editor-fab"
|
||||
aria-label={if @editing, do: "Show editor", else: "Edit page"}
|
||||
aria-label={if @any_editing, do: "Show editor", else: "Edit"}
|
||||
>
|
||||
<.edit_pencil_svg />
|
||||
<span>{if @editing, do: "Show editor", else: "Edit page"}</span>
|
||||
<span>{if @any_editing, do: "Show editor", else: "Edit"}</span>
|
||||
<span :if={@editing && @editor_dirty} class="editor-fab-dirty" aria-label="Unsaved changes" />
|
||||
</button>
|
||||
|
||||
<%!-- Overlay to catch taps outside the panel --%>
|
||||
<div
|
||||
:if={@editor_sheet_state == :open}
|
||||
class="editor-overlay"
|
||||
phx-click="editor_set_sheet_state"
|
||||
phx-value-state="collapsed"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<%!-- Editor panel: slides in/out --%>
|
||||
<aside
|
||||
id="editor-panel"
|
||||
class="editor-panel"
|
||||
role="region"
|
||||
aria-label="Page editor"
|
||||
aria-label="Site editor"
|
||||
aria-hidden={to_string(@editor_sheet_state == :collapsed)}
|
||||
data-state={@editor_sheet_state}
|
||||
data-editing={to_string(@editing)}
|
||||
data-editing={to_string(@any_editing)}
|
||||
phx-hook="EditorSheet"
|
||||
>
|
||||
<%!-- Drag handle for mobile resizing --%>
|
||||
<div class="editor-panel-drag-handle" data-drag-handle>
|
||||
<div class="editor-panel-drag-handle-bar" />
|
||||
</div>
|
||||
|
||||
<div class="editor-panel-header">
|
||||
<div class="editor-panel-header-left">
|
||||
<span class="editor-panel-title">Page editor</span>
|
||||
<span :if={@editor_dirty} class="editor-panel-dirty" aria-live="polite">
|
||||
<span class="editor-panel-title">{@title}</span>
|
||||
<span :if={@editing && @editor_dirty} class="editor-panel-dirty" aria-live="polite">
|
||||
<span class="editor-panel-dirty-dot" aria-hidden="true" />
|
||||
<span>Unsaved</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="editor-panel-header-actions">
|
||||
<button
|
||||
:if={@editor_save_status == :saved}
|
||||
:if={@editor_active_tab == :page && @editor_save_status == :saved}
|
||||
type="button"
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
disabled
|
||||
@@ -1100,7 +1156,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
Saved ✓
|
||||
</button>
|
||||
<button
|
||||
:if={@editor_save_status != :saved}
|
||||
:if={@editor_active_tab == :page && @editor_save_status != :saved}
|
||||
type="button"
|
||||
phx-click="editor_save"
|
||||
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
|
||||
@@ -1108,9 +1164,66 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="editor_set_sheet_state"
|
||||
phx-value-state="collapsed"
|
||||
class="editor-panel-close"
|
||||
aria-label="Close editor"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Tab bar --%>
|
||||
<div class="editor-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
phx-click="editor_set_tab"
|
||||
phx-value-tab="page"
|
||||
class={["editor-tab", @editor_active_tab == :page && "editor-tab-active"]}
|
||||
aria-selected={to_string(@editor_active_tab == :page)}
|
||||
disabled={!@has_editable_page}
|
||||
title={
|
||||
if @has_editable_page, do: "Edit page blocks", else: "This page has no editable blocks"
|
||||
}
|
||||
>
|
||||
Page
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
phx-click="editor_set_tab"
|
||||
phx-value-tab="theme"
|
||||
class={["editor-tab", @editor_active_tab == :theme && "editor-tab-active"]}
|
||||
aria-selected={to_string(@editor_active_tab == :theme)}
|
||||
>
|
||||
Theme
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
phx-click="editor_set_tab"
|
||||
phx-value-tab="settings"
|
||||
class={["editor-tab", @editor_active_tab == :settings && "editor-tab-active"]}
|
||||
aria-selected={to_string(@editor_active_tab == :settings)}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-panel-content">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
|
||||
@@ -326,10 +326,6 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
||||
end
|
||||
end
|
||||
|
||||
defp has_valid_logo?(socket) do
|
||||
socket.assigns.theme_settings.show_logo && socket.assigns.logo_image != nil
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_theme", _params, socket) do
|
||||
socket = put_flash(socket, :info, "Theme saved successfully")
|
||||
@@ -452,6 +448,10 @@ defmodule BerrypodWeb.Admin.Theme.Index 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"
|
||||
|
||||
@@ -330,8 +330,7 @@
|
||||
<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>
|
||||
Short name <span class="admin-text-tertiary">— appears under home screen icon</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -441,7 +440,7 @@
|
||||
</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">
|
||||
@@ -521,7 +520,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Presets Section -->
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Start with a preset</label>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
defmodule BerrypodWeb.PageEditorHook do
|
||||
@moduledoc """
|
||||
LiveView on_mount hook for the live page editor sidebar.
|
||||
LiveView on_mount hook for the unified on-site editor.
|
||||
|
||||
Mounted in the public_shop live_session. When an admin visits any shop
|
||||
page with `?edit=true` in the URL, this hook activates editing mode:
|
||||
loads a working copy of the page's blocks, attaches event handlers for
|
||||
block manipulation, and sets assigns that trigger the editor sidebar
|
||||
in `PageRenderer.render_page/1`.
|
||||
page, this hook enables editing capabilities:
|
||||
|
||||
1. **Page editing** — loads a working copy of the page's blocks, attaches
|
||||
event handlers for block manipulation
|
||||
2. **Theme editing** — provides live theme customisation on the actual shop
|
||||
|
||||
The hook manages a tabbed editor panel with Page, Theme, and Settings tabs.
|
||||
Non-admin users are unaffected — the hook just assigns `editing: false`.
|
||||
|
||||
## Actions
|
||||
@@ -18,12 +20,15 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2]
|
||||
|
||||
alias Berrypod.{Media, Settings}
|
||||
alias Berrypod.Pages
|
||||
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
|
||||
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
|
||||
|
||||
def on_mount(:mount_page_editor, _params, _session, socket) do
|
||||
socket =
|
||||
socket
|
||||
# Page editing state
|
||||
|> assign(:editing, false)
|
||||
|> assign(:editing_blocks, nil)
|
||||
|> assign(:editor_dirty, false)
|
||||
@@ -43,6 +48,18 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_image_picker_images, [])
|
||||
|> assign(:editor_image_picker_search, "")
|
||||
|> assign(:editor_save_status, :idle)
|
||||
# Unified editor tab state (:page | :theme | :settings)
|
||||
|> assign(:editor_active_tab, :page)
|
||||
# Theme editing state
|
||||
|> assign(:theme_editing, false)
|
||||
|> assign(:theme_editor_settings, nil)
|
||||
|> assign(:theme_editor_active_preset, nil)
|
||||
|> assign(:theme_editor_logo_image, nil)
|
||||
|> assign(:theme_editor_header_image, nil)
|
||||
|> assign(:theme_editor_icon_image, nil)
|
||||
|> assign(:theme_editor_contrast_warning, :ok)
|
||||
|> assign(:theme_editor_customise_open, false)
|
||||
|> assign(:theme_editor_presets, Presets.all_with_descriptions())
|
||||
|> attach_hook(:editor_params, :handle_params, &handle_editor_params/3)
|
||||
|> attach_hook(:editor_events, :handle_event, &handle_editor_event/3)
|
||||
|> attach_hook(:editor_info, :handle_info, &handle_editor_info/2)
|
||||
@@ -84,7 +101,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|
||||
# set_sheet_state can be called even when not editing (from JS click-outside)
|
||||
defp handle_editor_event("editor_set_sheet_state", %{"state" => state_str}, socket) do
|
||||
if socket.assigns.is_admin and socket.assigns[:page] do
|
||||
if socket.assigns.is_admin do
|
||||
state = if state_str == "open", do: :open, else: :collapsed
|
||||
{:halt, assign(socket, :editor_sheet_state, state)}
|
||||
else
|
||||
@@ -92,6 +109,62 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
end
|
||||
end
|
||||
|
||||
# Tab switching for unified editor
|
||||
defp handle_editor_event("editor_set_tab", %{"tab" => tab_str}, socket) do
|
||||
if socket.assigns.is_admin do
|
||||
tab = String.to_existing_atom(tab_str)
|
||||
|
||||
socket =
|
||||
case tab do
|
||||
:theme ->
|
||||
# Load theme state if not already loaded
|
||||
if socket.assigns.theme_editing do
|
||||
assign(socket, :editor_active_tab, :theme)
|
||||
else
|
||||
enter_theme_edit_mode(socket)
|
||||
end
|
||||
|
||||
:page ->
|
||||
# Enter page edit mode if we have a page and aren't already editing
|
||||
socket = assign(socket, :editor_active_tab, :page)
|
||||
|
||||
if socket.assigns[:page] && !socket.assigns.editing do
|
||||
enter_edit_mode(socket)
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
:settings ->
|
||||
# Ensure theme state is loaded for settings that need it
|
||||
socket =
|
||||
if socket.assigns.theme_editing do
|
||||
socket
|
||||
else
|
||||
load_theme_state(socket)
|
||||
end
|
||||
|
||||
assign(socket, :editor_active_tab, :settings)
|
||||
end
|
||||
|
||||
{:halt, socket}
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# Toggle theme editing mode
|
||||
defp handle_editor_event("editor_toggle_theme", _params, socket) do
|
||||
if socket.assigns.is_admin do
|
||||
if socket.assigns.theme_editing do
|
||||
{:halt, exit_theme_edit_mode(socket)}
|
||||
else
|
||||
{:halt, enter_theme_edit_mode(socket)}
|
||||
end
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_editor_event("editor_" <> action, params, socket) do
|
||||
if socket.assigns.editing do
|
||||
handle_editor_action(action, params, socket)
|
||||
@@ -100,6 +173,15 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
end
|
||||
end
|
||||
|
||||
# Theme editing events
|
||||
defp handle_editor_event("theme_" <> action, params, socket) do
|
||||
if socket.assigns.is_admin && socket.assigns.theme_editing do
|
||||
handle_theme_action(action, params, socket)
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
|
||||
|
||||
# ── Block manipulation actions ───────────────────────────────────
|
||||
@@ -417,6 +499,200 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
# Catch-all for unknown editor actions
|
||||
defp handle_editor_action(_action, _params, socket), do: {:halt, socket}
|
||||
|
||||
# ── Theme editing actions ───────────────────────────────────────────
|
||||
|
||||
# Settings stored outside the theme JSON (site_name, site_description)
|
||||
@standalone_settings ~w(site_name site_description)
|
||||
|
||||
defp handle_theme_action("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, &BerrypodWeb.Endpoint.static_path/1)
|
||||
|
||||
header_image = socket.assigns.theme_editor_header_image
|
||||
contrast_warning = compute_header_contrast(theme_settings, header_image)
|
||||
|
||||
socket =
|
||||
socket
|
||||
# Update editor state
|
||||
|> assign(:theme_editor_settings, theme_settings)
|
||||
|> assign(:theme_editor_active_preset, preset_atom)
|
||||
|> assign(:theme_editor_contrast_warning, contrast_warning)
|
||||
# Update shop state so layout reflects changes live
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|
||||
{:halt, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_theme_action(
|
||||
"update_setting",
|
||||
%{"field" => field, "setting_value" => value},
|
||||
socket
|
||||
)
|
||||
when field in @standalone_settings do
|
||||
Settings.put_setting(field, value, "string")
|
||||
# Also update the main assigns so ThemeHook sees the change
|
||||
{:halt, assign(socket, String.to_existing_atom(field), value)}
|
||||
end
|
||||
|
||||
defp handle_theme_action(
|
||||
"update_setting",
|
||||
%{"field" => field, "setting_value" => value},
|
||||
socket
|
||||
) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
update_theme_setting(socket, %{field_atom => value}, field)
|
||||
end
|
||||
|
||||
defp handle_theme_action("update_setting", %{"field" => field} = params, socket)
|
||||
when field in @standalone_settings do
|
||||
value = params[field]
|
||||
|
||||
if value do
|
||||
Settings.put_setting(field, value, "string")
|
||||
{:halt, assign(socket, String.to_existing_atom(field), value)}
|
||||
else
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_theme_action("update_setting", %{"field" => field} = params, socket) do
|
||||
value = params[field] || params["#{field}_text"] || params["value"]
|
||||
|
||||
if value do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
update_theme_setting(socket, %{field_atom => value}, field)
|
||||
else
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_theme_action("update_color", %{"field" => field, "value" => value}, socket) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
update_theme_setting(socket, %{field_atom => value}, field)
|
||||
end
|
||||
|
||||
defp handle_theme_action("toggle_setting", %{"field" => field}, socket) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
current_value = Map.get(socket.assigns.theme_editor_settings, field_atom)
|
||||
new_value = !current_value
|
||||
|
||||
# Prevent turning off show_site_name when there's no logo
|
||||
if field_atom == :show_site_name && new_value == false && !has_valid_logo?(socket) do
|
||||
{:halt, socket}
|
||||
else
|
||||
update_theme_setting(socket, %{field_atom => new_value}, field)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_theme_action("toggle_customise", _params, socket) do
|
||||
{:halt,
|
||||
assign(socket, :theme_editor_customise_open, !socket.assigns.theme_editor_customise_open)}
|
||||
end
|
||||
|
||||
defp handle_theme_action("remove_logo", _params, socket) do
|
||||
if logo = socket.assigns.theme_editor_logo_image do
|
||||
Media.delete_image(logo)
|
||||
end
|
||||
|
||||
{:ok, theme_settings} =
|
||||
Settings.update_theme_settings(%{logo_image_id: nil, show_site_name: true})
|
||||
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_editor_logo_image, nil)
|
||||
|> assign(:logo_image, nil)
|
||||
|> assign(:theme_editor_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
defp handle_theme_action("remove_header", _params, socket) do
|
||||
if header = socket.assigns.theme_editor_header_image do
|
||||
Media.delete_image(header)
|
||||
end
|
||||
|
||||
Settings.update_theme_settings(%{header_image_id: nil})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_editor_header_image, nil)
|
||||
|> assign(:header_image, nil)
|
||||
|> assign(:theme_editor_contrast_warning, :ok)
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
defp handle_theme_action("remove_icon", _params, socket) do
|
||||
if icon = socket.assigns.theme_editor_icon_image do
|
||||
Media.delete_image(icon)
|
||||
end
|
||||
|
||||
Settings.update_theme_settings(%{icon_image_id: nil})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:theme_editor_icon_image, nil)
|
||||
|> assign(:icon_image, nil)
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
# Catch-all for unknown theme actions
|
||||
defp handle_theme_action(_action, _params, socket), do: {:halt, socket}
|
||||
|
||||
# Helper to update a theme setting and regenerate CSS
|
||||
defp update_theme_setting(socket, attrs, field) do
|
||||
case Settings.update_theme_settings(attrs) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css =
|
||||
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
|
||||
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
socket =
|
||||
socket
|
||||
# Update editor state
|
||||
|> assign(:theme_editor_settings, theme_settings)
|
||||
|> assign(:theme_editor_active_preset, active_preset)
|
||||
|> maybe_recompute_contrast(field)
|
||||
# Update shop state so layout reflects changes live
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|
||||
{:halt, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_recompute_contrast(socket, field)
|
||||
when field in ["mood", "header_background_enabled"] do
|
||||
header_image = socket.assigns.theme_editor_header_image
|
||||
theme_settings = socket.assigns.theme_editor_settings
|
||||
contrast_warning = compute_header_contrast(theme_settings, header_image)
|
||||
assign(socket, :theme_editor_contrast_warning, contrast_warning)
|
||||
end
|
||||
|
||||
defp maybe_recompute_contrast(socket, _field), do: socket
|
||||
|
||||
defp has_valid_logo?(socket) do
|
||||
socket.assigns.theme_editor_settings.show_logo &&
|
||||
socket.assigns.theme_editor_logo_image != nil
|
||||
end
|
||||
|
||||
# ── Private helpers ──────────────────────────────────────────────
|
||||
|
||||
defp enter_edit_mode(socket) do
|
||||
@@ -458,7 +734,6 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_allowed_blocks, nil)
|
||||
|> assign(:editor_live_region_message, nil)
|
||||
|> assign(:editor_sidebar_open, true)
|
||||
|> assign(:editor_sheet_state, :collapsed)
|
||||
|> assign(:editor_image_picker_block_id, nil)
|
||||
|> assign(:editor_image_picker_field_key, nil)
|
||||
|> assign(:editor_image_picker_images, [])
|
||||
@@ -491,4 +766,61 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
extra = Pages.load_block_data(blocks, socket.assigns)
|
||||
Enum.reduce(extra, socket, fn {key, value}, acc -> assign(acc, key, value) end)
|
||||
end
|
||||
|
||||
# ── Theme editing helpers ───────────────────────────────────────────
|
||||
|
||||
# Load theme state without changing tabs (for settings tab that needs theme data)
|
||||
defp load_theme_state(socket) do
|
||||
theme_settings = Settings.get_theme_settings()
|
||||
|
||||
generated_css =
|
||||
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
|
||||
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
logo_image = Media.get_logo()
|
||||
header_image = Media.get_header()
|
||||
icon_image = Media.get_icon()
|
||||
|
||||
contrast_warning = compute_header_contrast(theme_settings, header_image)
|
||||
|
||||
socket
|
||||
|> assign(:theme_editing, true)
|
||||
|> assign(:theme_editor_settings, theme_settings)
|
||||
|> assign(:theme_editor_active_preset, active_preset)
|
||||
|> assign(:theme_editor_logo_image, logo_image)
|
||||
|> assign(:theme_editor_header_image, header_image)
|
||||
|> assign(:theme_editor_icon_image, icon_image)
|
||||
|> assign(:theme_editor_contrast_warning, contrast_warning)
|
||||
|> assign(:theme_editor_customise_open, false)
|
||||
# Update both editor and shop state
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:editor_sheet_state, :open)
|
||||
end
|
||||
|
||||
defp enter_theme_edit_mode(socket) do
|
||||
socket
|
||||
|> load_theme_state()
|
||||
|> assign(:editor_active_tab, :theme)
|
||||
end
|
||||
|
||||
defp exit_theme_edit_mode(socket) do
|
||||
socket
|
||||
|> assign(:theme_editing, false)
|
||||
|> assign(:theme_editor_settings, nil)
|
||||
|> assign(:theme_editor_active_preset, nil)
|
||||
|> assign(:theme_editor_contrast_warning, :ok)
|
||||
|> assign(:theme_editor_customise_open, false)
|
||||
end
|
||||
|
||||
defp compute_header_contrast(theme_settings, header_image) do
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
@@ -81,14 +81,18 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
</main>
|
||||
</.shop_layout>
|
||||
|
||||
<%!-- Editor sheet for page editing --%>
|
||||
<%!-- Editor sheet for page/theme/settings editing --%>
|
||||
<.editor_sheet
|
||||
editing={@editing}
|
||||
theme_editing={Map.get(assigns, :theme_editing, false)}
|
||||
editor_dirty={@editor_dirty}
|
||||
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
|
||||
editor_save_status={@editor_save_status}
|
||||
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
|
||||
has_editable_page={@page != nil}
|
||||
>
|
||||
<.editor_sheet_content
|
||||
<.editor_panel_content
|
||||
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
|
||||
page={@page}
|
||||
editing_blocks={@editing_blocks}
|
||||
editor_history={@editor_history}
|
||||
@@ -103,13 +107,247 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
editor_image_picker_images={@editor_image_picker_images}
|
||||
editor_image_picker_search={@editor_image_picker_search}
|
||||
editor_at_defaults={Map.get(assigns, :editor_at_defaults, true)}
|
||||
theme_editor_settings={Map.get(assigns, :theme_editor_settings)}
|
||||
theme_editor_active_preset={Map.get(assigns, :theme_editor_active_preset)}
|
||||
theme_editor_presets={Map.get(assigns, :theme_editor_presets, [])}
|
||||
theme_editor_customise_open={Map.get(assigns, :theme_editor_customise_open, false)}
|
||||
site_name={Map.get(assigns, :site_name, "")}
|
||||
/>
|
||||
</.editor_sheet>
|
||||
"""
|
||||
end
|
||||
|
||||
# Editor panel content dispatcher - shows content based on active tab
|
||||
attr :editor_active_tab, :atom, default: :page
|
||||
attr :page, :map, default: nil
|
||||
attr :editing_blocks, :list, default: nil
|
||||
attr :editor_history, :list, default: []
|
||||
attr :editor_future, :list, default: []
|
||||
attr :editor_dirty, :boolean, default: false
|
||||
attr :editor_live_region_message, :string, default: nil
|
||||
attr :editor_expanded, :any, default: nil
|
||||
attr :editor_show_picker, :boolean, default: false
|
||||
attr :editor_picker_filter, :string, default: ""
|
||||
attr :editor_allowed_blocks, :list, default: nil
|
||||
attr :editor_image_picker_block_id, :string, default: nil
|
||||
attr :editor_image_picker_images, :list, default: []
|
||||
attr :editor_image_picker_search, :string, default: ""
|
||||
attr :editor_at_defaults, :boolean, default: true
|
||||
attr :theme_editor_settings, :map, default: nil
|
||||
attr :theme_editor_active_preset, :atom, default: nil
|
||||
attr :theme_editor_presets, :list, default: []
|
||||
attr :theme_editor_customise_open, :boolean, default: false
|
||||
attr :site_name, :string, default: ""
|
||||
|
||||
defp editor_panel_content(%{editor_active_tab: :page} = assigns) do
|
||||
~H"""
|
||||
<.editor_sheet_content
|
||||
page={@page}
|
||||
editing_blocks={@editing_blocks}
|
||||
editor_history={@editor_history}
|
||||
editor_future={@editor_future}
|
||||
editor_dirty={@editor_dirty}
|
||||
editor_live_region_message={@editor_live_region_message}
|
||||
editor_expanded={@editor_expanded}
|
||||
editor_show_picker={@editor_show_picker}
|
||||
editor_picker_filter={@editor_picker_filter}
|
||||
editor_allowed_blocks={@editor_allowed_blocks}
|
||||
editor_image_picker_block_id={@editor_image_picker_block_id}
|
||||
editor_image_picker_images={@editor_image_picker_images}
|
||||
editor_image_picker_search={@editor_image_picker_search}
|
||||
editor_at_defaults={@editor_at_defaults}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
defp editor_panel_content(%{editor_active_tab: :theme} = assigns) do
|
||||
~H"""
|
||||
<.theme_editor_content
|
||||
theme_editor_settings={@theme_editor_settings}
|
||||
theme_editor_active_preset={@theme_editor_active_preset}
|
||||
theme_editor_presets={@theme_editor_presets}
|
||||
theme_editor_customise_open={@theme_editor_customise_open}
|
||||
site_name={@site_name}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
defp editor_panel_content(%{editor_active_tab: :settings} = assigns) do
|
||||
~H"""
|
||||
<.settings_editor_content page={@page} site_name={@site_name} />
|
||||
"""
|
||||
end
|
||||
|
||||
# Theme editor content - shows theme controls
|
||||
attr :theme_editor_settings, :map, default: nil
|
||||
attr :theme_editor_active_preset, :atom, default: nil
|
||||
attr :theme_editor_presets, :list, default: []
|
||||
attr :theme_editor_customise_open, :boolean, default: false
|
||||
attr :site_name, :string, default: ""
|
||||
|
||||
defp theme_editor_content(assigns) do
|
||||
~H"""
|
||||
<div class="editor-theme-content">
|
||||
<%= if @theme_editor_settings do %>
|
||||
<%!-- Shop name --%>
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Shop name</label>
|
||||
<form phx-change="theme_update_setting" phx-value-field="site_name">
|
||||
<input
|
||||
type="text"
|
||||
name="site_name"
|
||||
value={@site_name}
|
||||
placeholder="Your shop name"
|
||||
class="admin-input"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<%!-- Presets --%>
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Preset</label>
|
||||
<div class="theme-presets">
|
||||
<%= for {preset_name, description} <- @theme_editor_presets do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="theme_apply_preset"
|
||||
phx-value-preset={preset_name}
|
||||
class={[
|
||||
"theme-preset",
|
||||
@theme_editor_active_preset == preset_name && "theme-preset-active"
|
||||
]}
|
||||
>
|
||||
<div class="theme-preset-name">{preset_name}</div>
|
||||
<div class="theme-preset-desc">{description}</div>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Mood --%>
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Colour mood</label>
|
||||
<div class="theme-chips">
|
||||
<%= for mood <- ["warm", "neutral", "cool", "dark"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="theme_update_setting"
|
||||
phx-value-field="mood"
|
||||
phx-value-setting_value={mood}
|
||||
class={["theme-chip", @theme_editor_settings.mood == mood && "theme-chip-active"]}
|
||||
>
|
||||
{mood}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Typography --%>
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Font style</label>
|
||||
<div class="theme-chips">
|
||||
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="theme_update_setting"
|
||||
phx-value-field="typography"
|
||||
phx-value-setting_value={typo}
|
||||
class={[
|
||||
"theme-chip",
|
||||
@theme_editor_settings.typography == typo && "theme-chip-active"
|
||||
]}
|
||||
>
|
||||
{typo}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Shape --%>
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Corner style</label>
|
||||
<div class="theme-chips">
|
||||
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="theme_update_setting"
|
||||
phx-value-field="shape"
|
||||
phx-value-setting_value={shape}
|
||||
class={["theme-chip", @theme_editor_settings.shape == shape && "theme-chip-active"]}
|
||||
>
|
||||
{shape}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- More options link --%>
|
||||
<details
|
||||
class="theme-customise"
|
||||
id="theme-customise-section"
|
||||
open={@theme_editor_customise_open}
|
||||
>
|
||||
<summary class="theme-customise-summary" phx-click="theme_toggle_customise">
|
||||
<span class="theme-customise-label">More options</span>
|
||||
<svg
|
||||
class="theme-customise-chevron"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="theme-customise-body">
|
||||
<p class="admin-text-secondary">
|
||||
For full theme customisation including branding, colours, and layout, <a
|
||||
href="/admin/theme"
|
||||
class="admin-link"
|
||||
>visit the theme editor</a>.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
<% else %>
|
||||
<p class="admin-text-secondary">Loading theme settings...</p>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Settings editor content - shows page/shop settings
|
||||
attr :page, :map, default: nil
|
||||
attr :site_name, :string, default: ""
|
||||
|
||||
defp settings_editor_content(assigns) do
|
||||
~H"""
|
||||
<div class="editor-settings-content">
|
||||
<%= if @page do %>
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Page</label>
|
||||
<p class="admin-text-secondary">{@page.title}</p>
|
||||
</div>
|
||||
<div class="theme-section">
|
||||
<p class="admin-text-secondary">
|
||||
Page settings like SEO, visibility, and slug editing coming soon.
|
||||
For now, <a href="/admin/pages" class="admin-link">manage pages in admin</a>.
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="theme-section">
|
||||
<p class="admin-text-secondary">
|
||||
This page doesn't have editable settings.
|
||||
<a href="/admin/settings" class="admin-link">Shop settings</a>
|
||||
can be changed in admin.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Editor sheet content - the block list and editing controls
|
||||
attr :page, :map, required: true
|
||||
attr :page, :map, default: nil
|
||||
attr :editing_blocks, :list, default: nil
|
||||
attr :editor_history, :list, default: []
|
||||
attr :editor_future, :list, default: []
|
||||
|
||||
Reference in New Issue
Block a user