berrypod/lib/berrypod_web/live/admin/pages/editor.ex
jamey 045be2ed7e
All checks were successful
deploy / deploy (push) Successful in 1m21s
add admin CRUD for custom CMS pages
New settings form for creating and editing custom page metadata
(title, slug, meta description, published, nav settings). Pages
index shows custom pages section with draft badges and delete.
Editor shows settings button for custom pages, hides reset to
defaults. 20 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:43:03 +00:00

539 lines
17 KiB
Elixir

defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.{Media, Pages}
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
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())
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)
|> assign(:image_picker_images, [])
|> assign(:image_picker_search, "")
|> assign(:is_custom_page, !Page.system_slug?(slug))}
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
# ── Image picker events ──────────────────────────────────────────
def handle_event(
"show_image_picker",
%{"block-id" => block_id, "field" => field_key},
socket
) do
images = Media.list_images()
{:noreply,
socket
|> assign(:image_picker_block_id, block_id)
|> assign(:image_picker_field_key, field_key)
|> assign(:image_picker_images, images)
|> assign(:image_picker_search, "")}
end
def handle_event("hide_image_picker", _params, socket) do
{:noreply,
socket
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)}
end
def handle_event("image_picker_search", %{"value" => value}, socket) do
{:noreply, assign(socket, :image_picker_search, value)}
end
def handle_event("pick_image", %{"image-id" => image_id}, socket) do
block_id = socket.assigns.image_picker_block_id
field_key = socket.assigns.image_picker_field_key
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => image_id}) do
{:ok, new_blocks} ->
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)}
:noop ->
{:noreply, socket}
end
end
def handle_event(
"clear_image",
%{"block-id" => block_id, "field" => field_key},
socket
) do
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do
{:ok, new_blocks} ->
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)}
: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>
<.link
:if={@is_custom_page}
navigate={~p"/admin/pages/#{@slug}/settings"}
class="admin-btn admin-btn-sm admin-btn-ghost"
>
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
</.link>
<button
:if={!@is_custom_page}
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}
/>
<%!-- Image picker modal --%>
<.image_picker
:if={@image_picker_block_id}
images={@image_picker_images}
search={@image_picker_search}
/>
</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