berrypod/lib/berrypod_web/live/admin/pages/editor.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

456 lines
14 KiB
Elixir

defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.{Media, Pages}
alias Berrypod.Pages.{BlockEditor, BlockTypes}
alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData}
import BerrypodWeb.BlockEditorComponents
@impl true
def mount(%{"slug" => slug}, _session, socket) do
page = Pages.get_page(slug)
allowed_blocks = BlockTypes.allowed_for(slug)
preview_data = %{
products: PreviewData.products(),
categories: PreviewData.categories(),
cart_items: PreviewData.cart_items()
}
{:ok,
socket
|> assign(:page_title, page.title)
|> assign(:slug, slug)
|> assign(:page_data, page)
|> assign(:blocks, page.blocks)
|> assign(:allowed_blocks, allowed_blocks)
|> assign(:dirty, false)
|> assign(:show_picker, false)
|> assign(:picker_filter, "")
|> assign(:expanded, MapSet.new())
|> assign(:live_region_message, nil)
|> assign(:show_preview, false)
|> assign(:preview_data, preview_data)
|> assign(:logo_image, Media.get_logo())
|> assign(:header_image, Media.get_header())}
end
# ── Block manipulation events ────────────────────────────────────
@impl true
def handle_event("move_up", %{"id" => block_id}, socket) do
case BlockEditor.move_up(socket.assigns.blocks, block_id) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event("move_down", %{"id" => block_id}, socket) do
case BlockEditor.move_down(socket.assigns.blocks, block_id) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event("remove_block", %{"id" => block_id}, socket) do
{:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.blocks, block_id)
{:noreply, apply_mutation(socket, new_blocks, message)}
end
def handle_event("duplicate_block", %{"id" => block_id}, socket) do
case BlockEditor.duplicate_block(socket.assigns.blocks, block_id) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event("add_block", %{"type" => type}, socket) do
case BlockEditor.add_block(socket.assigns.blocks, type) do
{:ok, new_blocks, message} ->
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> assign(:show_picker, false)
|> assign(:live_region_message, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event("update_block_settings", params, socket) do
block_id = params["block_id"]
new_settings = params["block_settings"] || %{}
case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do
{:ok, new_blocks} ->
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)}
:noop ->
{:noreply, socket}
end
end
# ── Repeater events ──────────────────────────────────────────────
def handle_event("repeater_add", %{"block-id" => block_id, "field" => field_key}, socket) do
case BlockEditor.repeater_add(socket.assigns.blocks, block_id, field_key) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event(
"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.blocks, block_id, field_key, index) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event(
"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.blocks, block_id, field_key, index, dir) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
# ── UI events ────────────────────────────────────────────────────
def handle_event("toggle_expand", %{"id" => block_id}, socket) do
expanded = socket.assigns.expanded
block = Enum.find(socket.assigns.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
{:noreply,
socket
|> assign(:expanded, new_expanded)
|> assign(:live_region_message, "#{block_name} settings #{action}")}
end
def handle_event("show_picker", _params, socket) do
{:noreply,
socket
|> assign(:show_picker, true)
|> assign(:picker_filter, "")}
end
def handle_event("hide_picker", _params, socket) do
{:noreply, assign(socket, :show_picker, false)}
end
def handle_event("filter_picker", %{"value" => value}, socket) do
{:noreply, assign(socket, :picker_filter, value)}
end
def handle_event("toggle_preview", _params, socket) do
{:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
end
# ── Page actions ─────────────────────────────────────────────────
def handle_event("save", _params, socket) do
%{slug: slug, blocks: blocks, page_data: page_data} = socket.assigns
case Pages.save_page(slug, %{title: page_data.title, blocks: blocks}) do
{:ok, _page} ->
{:noreply,
socket
|> assign(:dirty, false)
|> put_flash(:info, "Page saved")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to save page")}
end
end
def handle_event("reset_defaults", _params, socket) do
slug = socket.assigns.slug
:ok = Pages.reset_page(slug)
page = Pages.get_page(slug)
{:noreply,
socket
|> assign(:blocks, page.blocks)
|> assign(:dirty, false)
|> put_flash(:info, "Page reset to defaults")}
end
# ── Render ───────────────────────────────────────────────────────
@impl true
def render(assigns) do
~H"""
<div id="page-editor" phx-hook="DirtyGuard" data-dirty={to_string(@dirty)}>
<.link
navigate={~p"/admin/pages"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
&larr; Pages
</.link>
<.header>
{@page_data.title}
<:actions>
<button
phx-click="toggle_preview"
class="admin-btn admin-btn-sm admin-btn-ghost page-editor-toggle-preview"
>
<.icon
name={if @show_preview, do: "hero-pencil-square", else: "hero-eye"}
class="size-4"
/>
{if @show_preview, do: "Edit", else: "Preview"}
</button>
<button
phx-click="reset_defaults"
data-confirm="Reset this page to its default layout? Your changes will be lost."
class="admin-btn admin-btn-sm admin-btn-ghost"
>
Reset to defaults
</button>
<button
phx-click="save"
class={["admin-btn admin-btn-sm admin-btn-primary", !@dirty && "opacity-50"]}
disabled={!@dirty}
>
Save
</button>
</:actions>
</.header>
<%!-- ARIA live region for screen reader announcements --%>
<div aria-live="polite" aria-atomic="true" class="sr-only">
{if @live_region_message, do: @live_region_message}
</div>
<%!-- Unsaved changes indicator --%>
<p :if={@dirty} class="admin-badge admin-badge-warning mt-4">
Unsaved changes
</p>
<div class="page-editor-container">
<%!-- Editor pane --%>
<div class={[
"page-editor-pane",
@show_preview && "page-editor-pane-hidden-mobile"
]}>
<%!-- Block list --%>
<div class="block-list" role="list" aria-label="Page blocks">
<.block_card
:for={{block, idx} <- Enum.with_index(@blocks)}
block={block}
idx={idx}
total={length(@blocks)}
expanded={@expanded}
/>
<div :if={@blocks == []} class="block-list-empty">
<p>No blocks on this page yet.</p>
</div>
</div>
<%!-- Add block button --%>
<div class="block-actions">
<button phx-click="show_picker" class="admin-btn admin-btn-outline block-add-btn">
<.icon name="hero-plus" class="size-4" /> Add block
</button>
</div>
</div>
<%!-- Preview pane --%>
<div class={[
"page-editor-preview-pane",
!@show_preview && "page-editor-preview-hidden-mobile"
]}>
<.preview_pane
slug={@slug}
blocks={@blocks}
page_data={@page_data}
preview_data={@preview_data}
theme_settings={@theme_settings}
generated_css={@generated_css}
logo_image={@logo_image}
header_image={@header_image}
/>
</div>
</div>
<%!-- Block picker modal --%>
<.block_picker
:if={@show_picker}
allowed_blocks={@allowed_blocks}
filter={@picker_filter}
/>
</div>
"""
end
# ── Preview ───────────────────────────────────────────────────────
defp preview_pane(assigns) do
page = %{slug: assigns.slug, title: assigns.page_data.title, blocks: assigns.blocks}
preview =
assigns
|> assign(:page, page)
|> assign(:mode, :preview)
|> assign(:products, assigns.preview_data.products)
|> assign(:categories, assigns.preview_data.categories)
|> assign(:cart_items, PreviewData.cart_drawer_items())
|> assign(:cart_count, 2)
|> assign(:cart_subtotal, "£72.00")
|> assign(:cart_drawer_open, false)
|> preview_page_context(assigns.slug)
extra = Pages.load_block_data(page.blocks, preview)
assigns = assign(assigns, :preview, assign(preview, extra))
~H"""
<div
class="page-editor-preview themed"
data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape}
data-density={@theme_settings.density}
data-grid={@theme_settings.grid_columns}
data-header={@theme_settings.header_layout}
data-sticky={to_string(@theme_settings.sticky_header)}
data-layout={@theme_settings.layout_width}
data-shadow={@theme_settings.card_shadow}
data-button-style={@theme_settings.button_style}
>
<style>
<%= Phoenix.HTML.raw(Fonts.generate_all_font_faces(&BerrypodWeb.Endpoint.static_path/1)) %>
<%= Phoenix.HTML.raw(@generated_css) %>
</style>
<BerrypodWeb.PageRenderer.render_page {@preview} />
</div>
"""
end
defp preview_page_context(assigns, "pdp") do
product = List.first(assigns.preview_data.products)
option_types = Map.get(product, :option_types) || []
variants = Map.get(product, :variants) || []
{selected_options, selected_variant} =
case variants do
[first | _] -> {first.options, first}
[] -> {%{}, nil}
end
available_options =
Enum.reduce(option_types, %{}, fn opt, acc ->
values = Enum.map(opt.values, & &1.title)
Map.put(acc, opt.name, values)
end)
display_price =
if selected_variant, do: selected_variant.price, else: product.cheapest_price
assigns
|> assign(:product, product)
|> assign(:gallery_images, build_gallery_images(product))
|> assign(:option_types, option_types)
|> assign(:selected_options, selected_options)
|> assign(:available_options, available_options)
|> assign(:display_price, display_price)
|> assign(:quantity, 1)
|> assign(:option_urls, %{})
end
defp preview_page_context(assigns, "cart") do
cart_items = assigns.preview_data.cart_items
subtotal =
Enum.reduce(cart_items, 0, fn item, acc ->
acc + item.product.cheapest_price * item.quantity
end)
assigns
|> assign(:cart_page_items, cart_items)
|> assign(:cart_page_subtotal, subtotal)
end
defp preview_page_context(assigns, "about"),
do: assign(assigns, :content_blocks, PreviewData.about_content())
defp preview_page_context(assigns, "delivery"),
do: assign(assigns, :content_blocks, PreviewData.delivery_content())
defp preview_page_context(assigns, "privacy"),
do: assign(assigns, :content_blocks, PreviewData.privacy_content())
defp preview_page_context(assigns, "terms"),
do: assign(assigns, :content_blocks, PreviewData.terms_content())
defp preview_page_context(assigns, "error") do
assign(assigns, %{
error_code: "404",
error_title: "Page Not Found",
error_description:
"Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved."
})
end
defp preview_page_context(assigns, _slug), do: assigns
defp build_gallery_images(product) do
(Map.get(product, :images) || [])
|> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|> Enum.reject(&is_nil/1)
end
# ── Helpers ──────────────────────────────────────────────────────
defp apply_mutation(socket, new_blocks, message) do
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> assign(:live_region_message, message)
end
end