berrypod/lib/berrypod_web/page_editor_hook.ex
jamey a039c8d53c
All checks were successful
deploy / deploy (push) Successful in 6m49s
add live page editor sidebar with collapsible UI
Admins can now edit pages directly on the live shop by clicking the
pencil icon in the header. A sidebar slides in with block management
controls (add, remove, reorder, edit settings, save, reset, done).

Key features:
- PageEditorHook on_mount with handle_params/event/info hooks
- BlockEditor pure functions extracted from admin editor
- Shared BlockEditorComponents with event_prefix namespacing
- Collapsible sidebar: X closes it, header pencil reopens it
- Backdrop overlay dismisses sidebar on tap
- Conditional admin.css loading for logged-in users
- content_body block now portable (textarea setting + rich text fallback)

13 integration tests, 26 unit tests, 1370 total passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:22:35 +00:00

361 lines
12 KiB
Elixir

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_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)
|> 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
|> assign(:editing_blocks, new_blocks)
|> assign(:editor_dirty, true)
|> assign(:editor_show_picker, false)
|> assign(:editor_live_region_message, message)
|> reload_block_data(new_blocks)
{: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} ->
socket =
socket
|> assign(:editing_blocks, new_blocks)
|> assign(:editor_dirty, true)
|> reload_block_data(new_blocks)
{:halt, socket}
: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
# ── 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)
|> 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)
|> 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_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)
end
defp exit_edit_mode(socket) do
socket
|> assign(:editing, false)
|> assign(:editing_blocks, nil)
|> assign(:editor_dirty, false)
|> 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)
end
defp apply_mutation(socket, new_blocks, message, type) do
socket =
socket
|> assign(:editing_blocks, new_blocks)
|> 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