add live preview pane to page editor
All checks were successful
deploy / deploy (push) Successful in 4m55s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-27 08:06:17 +00:00
parent 6fbd654d57
commit b340c24aa1
4 changed files with 341 additions and 23 deletions

View File

@ -458,7 +458,7 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details
See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan
### Page Editor ### Page Editor
**Status:** In progress — Stage 7 of 9 complete, 1320 tests **Status:** In progress — Stage 7b of 9 complete, 1326 tests
Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven). Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven).
@ -469,7 +469,8 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON
4. ~~Wire shop pages — Collection, PDP, Cart, Search~~ 4. ~~Wire shop pages — Collection, PDP, Cart, Search~~
5. ~~Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor~~ 5. ~~Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor~~
6. ~~Admin editor — page list + block management~~ ✅ (`660fda9`) 6. ~~Admin editor — page list + block management~~ ✅ (`660fda9`)
7. ~~Admin editor — inline block settings editing~~ 7. ~~Admin editor — inline block settings editing~~ ✅ (`3f97742`)
7b. ~~SettingsField struct + repeater field type for info_card items~~ ✅ (`6fbd654`)
8. **Next →** Live preview — split layout with real-time preview 8. **Next →** Live preview — split layout with real-time preview
9. Undo/redo + polish — history stacks, keyboard shortcuts, animations 9. Undo/redo + polish — history stacks, keyboard shortcuts, animations
@ -480,7 +481,8 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON
- `lib/berrypod_web/live/admin/pages/` — Index (page list) + Editor (block management) - `lib/berrypod_web/live/admin/pages/` — Index (page list) + Editor (block management)
- `test/berrypod/pages_test.exs` — 34 tests - `test/berrypod/pages_test.exs` — 34 tests
- `test/berrypod_web/page_renderer_test.exs` — 18 tests - `test/berrypod_web/page_renderer_test.exs` — 18 tests
- `test/berrypod_web/live/admin/pages_test.exs` — 36 tests - `lib/berrypod/pages/settings_field.ex` — typed struct for settings schema fields
- `test/berrypod_web/live/admin/pages_test.exs` — 42 tests
See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for full plan See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for full plan

View File

@ -1144,6 +1144,74 @@
color: color-mix(in oklch, var(--t-text-primary) 30%, transparent); color: color-mix(in oklch, var(--t-text-primary) 30%, transparent);
} }
/* Page editor split layout */
.page-editor-container {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.page-editor-pane-hidden-mobile {
display: none;
}
.page-editor-preview-hidden-mobile {
display: none;
}
.page-editor-preview-pane {
border: 1px solid color-mix(in oklch, var(--t-text-primary) 15%, transparent);
border-radius: 0.5rem;
overflow: hidden;
}
.page-editor-preview {
transform: scale(0.55);
transform-origin: top left;
width: 181.82%; /* 1/0.55 */
pointer-events: none;
}
.page-editor-toggle-preview {
display: inline-flex;
}
@media (min-width: 64em) {
.page-editor-container {
flex-direction: row;
gap: 1.5rem;
}
.page-editor-pane {
flex: 1;
min-width: 0;
overflow-y: auto;
}
.page-editor-pane-hidden-mobile {
display: block;
}
.page-editor-preview-pane {
flex: 1;
min-width: 0;
max-height: calc(100vh - 14rem);
overflow-y: auto;
position: sticky;
top: 1rem;
}
.page-editor-preview-hidden-mobile {
display: block;
}
.page-editor-toggle-preview {
display: none;
}
}
/* Block list in editor */ /* Block list in editor */
.block-list { .block-list {

View File

@ -1,14 +1,22 @@
defmodule BerrypodWeb.Admin.Pages.Editor do defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.Pages alias Berrypod.{Media, Pages}
alias Berrypod.Pages.{BlockTypes, Defaults, SettingsField} alias Berrypod.Pages.{BlockTypes, Defaults}
alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData}
@impl true @impl true
def mount(%{"slug" => slug}, _session, socket) do def mount(%{"slug" => slug}, _session, socket) do
page = Pages.get_page(slug) page = Pages.get_page(slug)
allowed_blocks = BlockTypes.allowed_for(slug) allowed_blocks = BlockTypes.allowed_for(slug)
preview_data = %{
products: PreviewData.products(),
categories: PreviewData.categories(),
cart_items: PreviewData.cart_items()
}
{:ok, {:ok,
socket socket
|> assign(:page_title, page.title) |> assign(:page_title, page.title)
@ -20,7 +28,11 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:show_picker, false) |> assign(:show_picker, false)
|> assign(:picker_filter, "") |> assign(:picker_filter, "")
|> assign(:expanded, MapSet.new()) |> assign(:expanded, MapSet.new())
|> assign(:live_region_message, nil)} |> 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 end
@impl true @impl true
@ -313,6 +325,10 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> put_flash(:info, "Page reset to defaults")} |> put_flash(:info, "Page reset to defaults")}
end end
def handle_event("toggle_preview", _params, socket) do
{:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -326,6 +342,16 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
<.header> <.header>
{@page_data.title} {@page_data.title}
<:actions> <: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 <button
phx-click="reset_defaults" phx-click="reset_defaults"
data-confirm="Reset this page to its default layout? Your changes will be lost." data-confirm="Reset this page to its default layout? Your changes will be lost."
@ -353,26 +379,51 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
Unsaved changes Unsaved changes
</p> </p>
<%!-- Block list --%> <div class="page-editor-container">
<div class="block-list" role="list" aria-label="Page blocks"> <%!-- Editor pane --%>
<.block_card <div class={[
:for={{block, idx} <- Enum.with_index(@blocks)} "page-editor-pane",
block={block} @show_preview && "page-editor-pane-hidden-mobile"
idx={idx} ]}>
total={length(@blocks)} <%!-- Block list --%>
expanded={@expanded} <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"> <div :if={@blocks == []} class="block-list-empty">
<p>No blocks on this page yet.</p> <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> </div>
</div>
<%!-- Add block button --%> <%!-- Preview pane --%>
<div class="block-actions"> <div class={[
<button phx-click="show_picker" class="admin-btn admin-btn-outline block-add-btn"> "page-editor-preview-pane",
<.icon name="hero-plus" class="size-4" /> Add block !@show_preview && "page-editor-preview-hidden-mobile"
</button> ]}>
<.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> </div>
<%!-- Block picker modal --%> <%!-- Block picker modal --%>
@ -385,6 +436,126 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
""" """
end end
# ── Preview ───────────────────────────────────────────────────────
defp preview_pane(assigns) do
# Build a temporary page struct from working state
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
# ── Block card ─────────────────────────────────────────────────────
defp block_card(assigns) do defp block_card(assigns) do
block_type = BlockTypes.get(assigns.block["type"]) block_type = BlockTypes.get(assigns.block["type"])
has_settings = has_settings?(assigns.block) has_settings = has_settings?(assigns.block)

View File

@ -624,6 +624,83 @@ defmodule BerrypodWeb.Admin.PagesTest do
end end
end end
describe "live preview" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "preview pane renders page content", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/pages/home")
# Preview pane should be in the DOM with the themed class
assert html =~ "page-editor-preview themed"
# Should render the page via PageRenderer (hero block is on home)
assert html =~ "page-editor-preview-pane"
end
test "toggle preview switches between edit and preview on mobile", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
# Initially: editor visible, preview hidden on mobile
assert has_element?(view, ".page-editor-pane:not(.page-editor-pane-hidden-mobile)")
assert has_element?(view, ".page-editor-preview-hidden-mobile")
# Toggle to preview
render_click(view, "toggle_preview")
assert has_element?(view, ".page-editor-pane-hidden-mobile")
assert has_element?(
view,
".page-editor-preview-pane:not(.page-editor-preview-hidden-mobile)"
)
# Toggle back to edit
render_click(view, "toggle_preview")
assert has_element?(view, ".page-editor-pane:not(.page-editor-pane-hidden-mobile)")
assert has_element?(view, ".page-editor-preview-hidden-mobile")
end
test "preview updates when block settings change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
page = Pages.get_page("home")
hero = Enum.at(page.blocks, 0)
render_click(view, "toggle_expand", %{"id" => hero["id"]})
render_change(view, "update_block_settings", %{
"block_id" => hero["id"],
"block_settings" => %{"title" => "Preview test title"}
})
# The preview should show the updated title
html = render(view)
assert html =~ "Preview test title"
end
test "preview updates after block reorder", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
page = Pages.get_page("home")
first_block = Enum.at(page.blocks, 0)
render_click(view, "move_down", %{"id" => first_block["id"]})
# Should still render without errors
html = render(view)
assert html =~ "page-editor-preview themed"
end
test "preview toggle button shows in header", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
# Toggle button should be present
assert has_element?(view, ".page-editor-toggle-preview")
end
end
defp count_repeater_items(html) do defp count_repeater_items(html) do
Regex.scan(~r/class="repeater-item"/, html) |> length() Regex.scan(~r/class="repeater-item"/, html) |> length()
end end