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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user