All checks were successful
deploy / deploy (push) Successful in 1m34s
Replace hardcoded header, footer and mobile nav with settings-driven loops. Nav items stored as JSON via Settings, loaded in ThemeHook with sensible defaults. New admin navigation editor at /admin/navigation for add/remove/reorder/save/reset. Mobile bottom nav also driven from header nav items with icon mapping by slug. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
541 lines
17 KiB
Elixir
541 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)
|
|
|> assign(:header_nav_items, BerrypodWeb.ThemeHook.default_header_nav())
|
|
|> assign(:footer_nav_items, BerrypodWeb.ThemeHook.default_footer_nav())
|
|
|> 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
|