All checks were successful
deploy / deploy (push) Successful in 1m21s
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>
539 lines
17 KiB
Elixir
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"
|
|
>
|
|
← 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
|