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()) |> 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 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 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 # 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 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