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, push_navigate: 2] alias Berrypod.Pages alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults} 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_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) |> 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 ──────────────────────────── defp handle_editor_params(_params, uri, socket) do parsed = URI.parse(uri) # Store the current path for reference (e.g. the Done button) {:cont, assign(socket, :editor_current_path, parsed.path)} 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 and socket.assigns[:page] do state = if state_str == "open", do: :open, else: :collapsed {:halt, assign(socket, :editor_sheet_state, state)} 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 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} # ── 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_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_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_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) 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 end