berrypod/lib/berrypod_web/page_editor_hook.ex

1029 lines
33 KiB
Elixir
Raw Normal View History

defmodule BerrypodWeb.PageEditorHook do
@moduledoc """
LiveView on_mount hook for the unified on-site editor.
Mounted in the public_shop live_session. When an admin visits any shop
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
- `:mount_page_editor` sets up editing assigns and attaches hooks
"""
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_page_slug, nil)
|> assign(:editor_dirty, false)
|> assign(:editor_at_defaults, true)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> assign(:editor_expanded, MapSet.new())
|> assign(:editor_show_picker, false)
|> assign(:editor_picker_filter, "")
|> assign(:editor_allowed_blocks, nil)
|> assign(:editor_live_region_message, nil)
|> assign(:editor_current_path, 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, [])
|> 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())
# Settings editing state
|> assign(:settings_dirty, false)
|> assign(:settings_save_status, :idle)
|> 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)
{:cont, socket}
end
# ── handle_params: track current path and restore editor state ────
defp handle_editor_params(params, uri, socket) do
parsed = URI.parse(uri)
socket =
socket
|> assign(:editor_current_path, parsed.path)
|> maybe_restore_editor_state(params)
{:cont, socket}
end
# Restore editor state from URL params on navigation
# Only activates state if not already in the requested state (avoids loops)
defp maybe_restore_editor_state(socket, params) do
if socket.assigns.is_admin do
requested_tab = params["edit"]
current_tab = socket.assigns.editor_active_tab
current_state = socket.assigns.editor_sheet_state
# If already in the correct state, don't re-apply
already_correct? =
current_state == :open && requested_tab &&
String.to_existing_atom(requested_tab) == current_tab
if already_correct? do
socket
else
case requested_tab do
"theme" ->
socket
|> assign(:editor_sheet_state, :open)
|> assign(:editor_active_tab, :theme)
|> maybe_enter_theme_mode()
"page" ->
socket
|> assign(:editor_sheet_state, :open)
|> assign(:editor_active_tab, :page)
|> maybe_enter_page_mode()
"settings" ->
socket
|> assign(:editor_sheet_state, :open)
|> assign(:editor_active_tab, :settings)
|> maybe_enter_theme_mode()
_ ->
socket
end
end
else
socket
end
end
defp maybe_enter_theme_mode(socket) do
if socket.assigns.theme_editing do
socket
else
load_theme_state(socket)
end
end
defp maybe_enter_page_mode(socket) do
if socket.assigns.editing do
socket
else
if socket.assigns[:page] do
enter_edit_mode(socket)
else
socket
end
end
end
# ── handle_info ─────────────────────────────────────────────────
defp handle_editor_info(:editor_clear_save_status, socket) do
{:halt, assign(socket, :editor_save_status, :idle)}
end
defp handle_editor_info(_msg, socket), do: {:cont, socket}
# ── handle_event: editor_* events ────────────────────────────────
# toggle_editing can be called even when not editing (to enter edit mode)
defp handle_editor_event("editor_toggle_editing", _params, socket) do
if socket.assigns.is_admin and socket.assigns[:page] do
if socket.assigns.editing do
{:halt, exit_edit_mode(socket)}
else
{:halt, enter_edit_mode(socket)}
end
else
{:cont, socket}
end
end
# 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 do
state = if state_str == "open", do: :open, else: :collapsed
{:halt, assign(socket, :editor_sheet_state, state)}
else
{:cont, socket}
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
# Initialize settings form from current page if not already set
# Only custom pages have editable settings (meta_description, published, etc.)
socket =
if is_nil(socket.assigns[:settings_form]) && socket.assigns[:page] &&
socket.assigns.page[:type] == "custom" do
init_settings_form(socket)
else
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)
else
{:cont, socket}
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
# Settings editing events (custom page settings)
defp handle_editor_event("settings_" <> action, params, socket) do
if socket.assigns.is_admin do
handle_settings_action(action, params, socket)
else
{:cont, socket}
end
end
defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
# ── Block manipulation actions ───────────────────────────────────
defp handle_editor_action("move_up", %{"id" => id}, socket) do
case BlockEditor.move_up(socket.assigns.editing_blocks, id) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :reorder)}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action("move_down", %{"id" => id}, socket) do
case BlockEditor.move_down(socket.assigns.editing_blocks, id) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :reorder)}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action("remove_block", %{"id" => id}, socket) do
{:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.editing_blocks, id)
{:halt, apply_mutation(socket, new_blocks, message, :content)}
end
defp handle_editor_action("duplicate_block", %{"id" => id}, socket) do
case BlockEditor.duplicate_block(socket.assigns.editing_blocks, id) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :content)}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action("add_block", %{"type" => type}, socket) do
case BlockEditor.add_block(socket.assigns.editing_blocks, type) do
{:ok, new_blocks, message} ->
socket =
socket
|> apply_mutation(new_blocks, message, :content)
|> assign(:editor_show_picker, false)
{:halt, socket}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action("update_block_settings", params, socket) do
block_id = params["block_id"]
new_settings = params["block_settings"] || %{}
case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, new_settings) do
{:ok, new_blocks} ->
{:halt, apply_mutation(socket, new_blocks, "Settings updated", :content)}
:noop ->
{:halt, socket}
end
end
# ── Repeater actions ─────────────────────────────────────────────
defp handle_editor_action(
"repeater_add",
%{"block-id" => block_id, "field" => field_key},
socket
) do
case BlockEditor.repeater_add(socket.assigns.editing_blocks, block_id, field_key) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :content)}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action(
"repeater_remove",
%{"block-id" => block_id, "field" => field_key, "index" => index_str},
socket
) do
index = String.to_integer(index_str)
case BlockEditor.repeater_remove(socket.assigns.editing_blocks, block_id, field_key, index) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :content)}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action(
"repeater_move",
%{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir},
socket
) do
index = String.to_integer(index_str)
case BlockEditor.repeater_move(
socket.assigns.editing_blocks,
block_id,
field_key,
index,
dir
) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :reorder)}
:noop ->
{:halt, socket}
end
end
# ── UI state actions ─────────────────────────────────────────────
defp handle_editor_action("toggle_expand", %{"id" => block_id}, socket) do
expanded = socket.assigns.editor_expanded
block = Enum.find(socket.assigns.editing_blocks, &(&1["id"] == block_id))
block_name = BlockEditor.block_display_name(block)
{new_expanded, action} =
if MapSet.member?(expanded, block_id) do
{MapSet.delete(expanded, block_id), "collapsed"}
else
{MapSet.put(expanded, block_id), "expanded"}
end
{:halt,
socket
|> assign(:editor_expanded, new_expanded)
|> assign(:editor_live_region_message, "#{block_name} settings #{action}")}
end
defp handle_editor_action("toggle_sidebar", _params, socket) do
{:halt, assign(socket, :editor_sidebar_open, !socket.assigns.editor_sidebar_open)}
end
defp handle_editor_action("show_picker", _params, socket) do
{:halt,
socket
|> assign(:editor_show_picker, true)
|> assign(:editor_picker_filter, "")}
end
defp handle_editor_action("hide_picker", _params, socket) do
{:halt, assign(socket, :editor_show_picker, false)}
end
defp handle_editor_action("filter_picker", %{"value" => value}, socket) do
{:halt, assign(socket, :editor_picker_filter, value)}
end
# ── Image picker actions ───────────────────────────────────────
defp handle_editor_action(
"show_image_picker",
%{"block-id" => block_id, "field" => field_key},
socket
) do
images = Berrypod.Media.list_images()
{:halt,
socket
|> assign(:editor_image_picker_block_id, block_id)
|> assign(:editor_image_picker_field_key, field_key)
|> assign(:editor_image_picker_images, images)
|> assign(:editor_image_picker_search, "")}
end
defp handle_editor_action("hide_image_picker", _params, socket) do
{:halt,
socket
|> assign(:editor_image_picker_block_id, nil)
|> assign(:editor_image_picker_field_key, nil)}
end
defp handle_editor_action("image_picker_search", %{"value" => value}, socket) do
{:halt, assign(socket, :editor_image_picker_search, value)}
end
defp handle_editor_action("pick_image", %{"image-id" => image_id}, socket) do
block_id = socket.assigns.editor_image_picker_block_id
field_key = socket.assigns.editor_image_picker_field_key
case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, %{
field_key => image_id
}) do
{:ok, new_blocks} ->
socket =
socket
|> apply_mutation(new_blocks, "Image selected", :content)
|> assign(:editor_image_picker_block_id, nil)
|> assign(:editor_image_picker_field_key, nil)
{:halt, socket}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action(
"clear_image",
%{"block-id" => block_id, "field" => field_key},
socket
) do
case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, %{
field_key => ""
}) do
{:ok, new_blocks} ->
{:halt, apply_mutation(socket, new_blocks, "Image cleared", :content)}
:noop ->
{:halt, socket}
end
end
# ── Undo / redo ──────────────────────────────────────────────────
defp handle_editor_action("undo", _params, socket) do
case socket.assigns.editor_history do
[prev | rest] ->
future = [socket.assigns.editing_blocks | socket.assigns.editor_future]
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, prev)
socket =
socket
|> assign(:editing_blocks, prev)
|> assign(:editor_history, rest)
|> assign(:editor_future, future)
|> assign(:editor_dirty, true)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_live_region_message, "Undone")
|> reload_block_data(prev)
{:halt, socket}
[] ->
{:halt, socket}
end
end
defp handle_editor_action("redo", _params, socket) do
case socket.assigns.editor_future do
[next | rest] ->
history = [socket.assigns.editing_blocks | socket.assigns.editor_history]
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, next)
socket =
socket
|> assign(:editing_blocks, next)
|> assign(:editor_history, history)
|> assign(:editor_future, rest)
|> assign(:editor_dirty, true)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_live_region_message, "Redone")
|> reload_block_data(next)
{:halt, socket}
[] ->
{:halt, socket}
end
end
# ── Page actions ─────────────────────────────────────────────────
defp handle_editor_action("save", _params, socket) do
%{page: page, editing_blocks: blocks} = socket.assigns
case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do
{:ok, _saved_page} ->
updated_page = Pages.get_page(page.slug)
at_defaults = Defaults.matches_defaults?(page.slug, updated_page.blocks)
Process.send_after(self(), :editor_clear_save_status, 2500)
socket =
socket
|> assign(:page, updated_page)
|> assign(:editing_blocks, updated_page.blocks)
|> assign(:editor_dirty, false)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> assign(:editor_save_status, :saved)
{:halt, socket}
{:error, _changeset} ->
{:halt, assign(socket, :editor_save_status, :error)}
end
end
defp handle_editor_action("reset_defaults", _params, socket) do
slug = socket.assigns.page.slug
default_blocks = Berrypod.Pages.Defaults.for_slug(slug).blocks
# Treat reset like any other mutation: push to history, mark dirty
{:halt, apply_mutation(socket, default_blocks, "Reset to defaults", :content)}
end
defp handle_editor_action("done", _params, socket) do
path = socket.assigns.editor_current_path || "/"
{:halt, push_navigate(socket, to: path)}
end
# 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
defp handle_theme_action(
"cancel_upload",
%{"ref" => ref, "upload" => upload_name},
socket
) do
upload_atom = String.to_existing_atom(upload_name)
{:halt, Phoenix.LiveView.cancel_upload(socket, upload_atom, ref)}
end
# Catch-all for unknown theme actions
defp handle_theme_action(_action, _params, socket), do: {:halt, socket}
# ── Settings editing actions (custom page settings) ────────────────
# Validate page settings form (live as-you-type)
defp handle_settings_action("validate_page", %{"page" => params}, socket) do
page = socket.assigns.page
# Only allow editing custom pages
if page && page.type == "custom" do
socket =
socket
|> assign(:settings_form, params)
|> assign(:settings_dirty, has_settings_changed?(page, params))
|> assign(:settings_save_status, :idle)
{:halt, socket}
else
{:halt, socket}
end
end
# Save page settings
defp handle_settings_action("save_page", %{"page" => params}, socket) do
page = socket.assigns.page
# Only allow editing custom pages
if page && page.type == "custom" do
# Normalize checkbox fields (unchecked checkboxes aren't sent)
params =
params
|> Map.put_new("published", "false")
|> Map.put_new("show_in_nav", "false")
old_slug = page.slug
# Fetch the Page struct from DB (assigns.page may be a map from cache)
page_struct = Pages.get_page_struct(page.slug)
case Pages.update_custom_page(page_struct, params) do
{:ok, updated_page} ->
# Reinitialize form from saved page
socket =
socket
|> assign(:page, updated_page)
|> assign(:settings_form, nil)
|> assign(:settings_dirty, false)
|> assign(:settings_save_status, :saved)
# Reinit form with new page values
socket = init_settings_form(socket)
# If slug changed, redirect to new URL
socket =
if updated_page.slug != old_slug do
push_navigate(socket, to: "/#{updated_page.slug}")
else
socket
end
{:halt, socket}
{:error, _changeset} ->
socket = assign(socket, :settings_save_status, :error)
{:halt, socket}
end
else
{:halt, socket}
end
end
# Catch-all for unknown settings actions
defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
# Check if settings have changed from current page values
defp has_settings_changed?(page, params) do
page.title != (params["title"] || "") or
page.slug != (params["slug"] || "") or
(page.meta_description || "") != (params["meta_description"] || "") or
to_string(page.published) != (params["published"] || "false") or
to_string(page.show_in_nav) != (params["show_in_nav"] || "false") or
(page.nav_label || "") != (params["nav_label"] || "") or
to_string(page.nav_position || 0) != (params["nav_position"] || "0")
end
# Initialize settings form from page values
defp init_settings_form(socket) do
page = socket.assigns.page
form = %{
"title" => page.title || "",
"slug" => page.slug || "",
"meta_description" => page.meta_description || "",
"published" => to_string(page.published),
"show_in_nav" => to_string(page.show_in_nav),
"nav_label" => page.nav_label || "",
"nav_position" => to_string(page.nav_position || 0)
}
assign(socket, :settings_form, form)
end
# 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
page = socket.assigns.page
allowed = BlockTypes.allowed_for(page.slug)
at_defaults = Defaults.matches_defaults?(page.slug, page.blocks)
socket
|> assign(:editing, true)
|> assign(:editing_blocks, page.blocks)
|> assign(:editor_page_slug, page.slug)
|> assign(:editor_dirty, false)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> assign(:editor_expanded, MapSet.new())
|> assign(:editor_show_picker, false)
|> assign(:editor_picker_filter, "")
|> assign(:editor_allowed_blocks, allowed)
|> assign(:editor_live_region_message, nil)
|> assign(:editor_sidebar_open, true)
|> assign(:editor_sheet_state, :open)
|> assign(:editor_image_picker_block_id, nil)
|> assign(:editor_image_picker_field_key, nil)
|> assign(:editor_image_picker_images, [])
|> assign(:editor_image_picker_search, "")
|> assign(:editor_save_status, :idle)
end
defp exit_edit_mode(socket) do
socket
|> assign(:editing, false)
|> assign(:editing_blocks, nil)
|> assign(:editor_page_slug, nil)
|> assign(:editor_dirty, false)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> assign(:editor_expanded, MapSet.new())
|> assign(:editor_show_picker, false)
|> assign(:editor_picker_filter, "")
|> assign(:editor_allowed_blocks, nil)
|> assign(:editor_live_region_message, nil)
|> assign(:editor_sidebar_open, true)
|> assign(:editor_image_picker_block_id, nil)
|> assign(:editor_image_picker_field_key, nil)
|> assign(:editor_image_picker_images, [])
|> assign(:editor_image_picker_search, "")
|> assign(:editor_save_status, :idle)
end
defp apply_mutation(socket, new_blocks, message, type) do
history =
[socket.assigns.editing_blocks | socket.assigns.editor_history] |> Enum.take(50)
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, new_blocks)
socket =
socket
|> assign(:editing_blocks, new_blocks)
|> assign(:editor_history, history)
|> assign(:editor_future, [])
|> assign(:editor_dirty, true)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_live_region_message, message)
case type do
:content -> reload_block_data(socket, new_blocks)
:reorder -> socket
end
end
defp reload_block_data(socket, blocks) 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