defmodule BerrypodWeb.PageEditorHook do @moduledoc """ LiveView on_mount hook for the live page editor sidebar. 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`. 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, put_flash: 3, push_navigate: 2] alias Berrypod.Pages alias Berrypod.Pages.{BlockEditor, BlockTypes} def on_mount(:mount_page_editor, _params, _session, socket) do socket = socket |> assign(:editing, false) |> assign(:editing_blocks, 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_current_path, 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, "") |> 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: detect ?edit=true ───────────────────────────── defp handle_editor_params(_params, uri, socket) do parsed = URI.parse(uri) query = URI.decode_query(parsed.query || "") wants_edit = query["edit"] == "true" # Always store the current path for the edit button and "done" navigation socket = assign(socket, :editor_current_path, parsed.path) cond do wants_edit and socket.assigns.is_admin and socket.assigns[:page] -> # Page already loaded — enter edit mode and halt (no need for module handle_params) {:halt, enter_edit_mode(socket)} wants_edit and socket.assigns.is_admin -> # Page not loaded yet (e.g. Shop.Content loads in handle_params), # defer initialisation until after the LiveView sets @page send(self(), :editor_deferred_init) {:cont, assign(socket, :editing, true)} socket.assigns.editing and not wants_edit -> # Exiting edit mode — halt since we've handled the transition {:halt, exit_edit_mode(socket)} true -> {:cont, socket} end end # ── handle_info: deferred init ─────────────────────────────────── defp handle_editor_info(:editor_deferred_init, socket) do if socket.assigns.editing and is_nil(socket.assigns.editing_blocks) and socket.assigns[:page] do {:halt, enter_edit_mode(socket)} else {:cont, socket} end end defp handle_editor_info(_msg, socket), do: {:cont, socket} # ── handle_event: editor_* events ──────────────────────────────── 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 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] socket = socket |> assign(:editing_blocks, prev) |> assign(:editor_history, rest) |> assign(:editor_future, future) |> assign(:editor_dirty, true) |> 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] socket = socket |> assign(:editing_blocks, next) |> assign(:editor_history, history) |> assign(:editor_future, rest) |> assign(:editor_dirty, true) |> 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) socket = socket |> assign(:page, updated_page) |> assign(:editing_blocks, updated_page.blocks) |> assign(:editor_dirty, false) |> assign(:editor_history, []) |> assign(:editor_future, []) |> put_flash(:info, "Page saved") {:halt, socket} {:error, _changeset} -> {:halt, put_flash(socket, :error, "Failed to save page")} end end defp handle_editor_action("reset_defaults", _params, socket) do slug = socket.assigns.page.slug :ok = Pages.reset_page(slug) page = Pages.get_page(slug) socket = socket |> assign(:page, page) |> assign(:editing_blocks, page.blocks) |> assign(:editor_dirty, false) |> assign(:editor_history, []) |> assign(:editor_future, []) |> reload_block_data(page.blocks) |> put_flash(:info, "Page reset to defaults") {:halt, socket} 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} # ── Private helpers ────────────────────────────────────────────── defp enter_edit_mode(socket) do page = socket.assigns.page allowed = BlockTypes.allowed_for(page.slug) socket |> assign(:editing, true) |> assign(:editing_blocks, page.blocks) |> 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, allowed) |> 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, "") end defp exit_edit_mode(socket) do socket |> assign(:editing, false) |> assign(:editing_blocks, 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, "") end defp apply_mutation(socket, new_blocks, message, type) do history = [socket.assigns.editing_blocks | socket.assigns.editor_history] |> Enum.take(50) socket = socket |> assign(:editing_blocks, new_blocks) |> assign(:editor_history, history) |> assign(:editor_future, []) |> assign(:editor_dirty, true) |> 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 end