berrypod/lib/berrypod_web/live/admin/pages/editor.ex

592 lines
19 KiB
Elixir
Raw Normal View History

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(:history, [])
|> assign(:future, [])
|> 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
|> apply_mutation(new_blocks, message)
|> assign(:show_picker, false)}
: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, apply_mutation(socket, new_blocks, "Settings updated")}
: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
|> apply_mutation(new_blocks, "Image selected")
|> 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, apply_mutation(socket, new_blocks, "Image cleared")}
: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(:history, [])
|> assign(:future, [])
|> assign(:dirty, false)
|> put_flash(:info, "Page reset to defaults")}
end
def handle_event("undo", _params, socket) do
case socket.assigns.history do
[prev | rest] ->
future = [socket.assigns.blocks | socket.assigns.future]
{:noreply,
socket
|> assign(:blocks, prev)
|> assign(:history, rest)
|> assign(:future, future)
|> assign(:dirty, prev != socket.assigns.page_data.blocks)
|> assign(:live_region_message, "Undone")}
[] ->
{:noreply, socket}
end
end
def handle_event("redo", _params, socket) do
case socket.assigns.future do
[next | rest] ->
history = [socket.assigns.blocks | socket.assigns.history]
{:noreply,
socket
|> assign(:blocks, next)
|> assign(:history, history)
|> assign(:future, rest)
|> assign(:dirty, true)
|> assign(:live_region_message, "Redone")}
[] ->
{:noreply, socket}
end
end
# ── Render ───────────────────────────────────────────────────────
@impl true
def render(assigns) do
~H"""
<div id="page-editor" phx-hook="EditorKeyboard" 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
phx-click="undo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @history == [] && "opacity-30"]}
disabled={@history == []}
aria-label="Undo"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
<button
phx-click="redo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @future == [] && "opacity-30"]}
disabled={@future == []}
aria-label="Redo"
>
<.icon name="hero-arrow-uturn-right" class="size-4" />
</button>
<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
history = [socket.assigns.blocks | socket.assigns.history] |> Enum.take(50)
socket
|> assign(:blocks, new_blocks)
|> assign(:history, history)
|> assign(:future, [])
|> assign(:dirty, true)
|> assign(:live_region_message, message)
end
end