From a039c8d53cf3d9827aa4d9dc56b4bed22e40eefc Mon Sep 17 00:00:00 2001 From: jamey Date: Fri, 27 Feb 2026 16:22:35 +0000 Subject: [PATCH] add live page editor sidebar with collapsible UI Admins can now edit pages directly on the live shop by clicking the pencil icon in the header. A sidebar slides in with block management controls (add, remove, reorder, edit settings, save, reset, done). Key features: - PageEditorHook on_mount with handle_params/event/info hooks - BlockEditor pure functions extracted from admin editor - Shared BlockEditorComponents with event_prefix namespacing - Collapsible sidebar: X closes it, header pencil reopens it - Backdrop overlay dismisses sidebar on tap - Conditional admin.css loading for logged-in users - content_body block now portable (textarea setting + rich text fallback) 13 integration tests, 26 unit tests, 1370 total passing. Co-Authored-By: Claude Opus 4.6 --- assets/css/admin/components.css | 101 +++ lib/berrypod/pages/block_editor.ex | 255 ++++++ lib/berrypod/pages/block_types.ex | 3 +- .../components/block_editor_components.ex | 405 ++++++++++ .../components/layouts/shop_root.html.heex | 3 + .../components/shop_components/layout.ex | 47 +- lib/berrypod_web/live/admin/pages/editor.ex | 747 +++--------------- lib/berrypod_web/page_editor_hook.ex | 360 +++++++++ lib/berrypod_web/page_renderer.ex | 144 +++- lib/berrypod_web/router.ex | 3 +- test/berrypod/pages/block_editor_test.exs | 187 +++++ test/berrypod_web/page_editor_hook_test.exs | 231 ++++++ 12 files changed, 1846 insertions(+), 640 deletions(-) create mode 100644 lib/berrypod/pages/block_editor.ex create mode 100644 lib/berrypod_web/components/block_editor_components.ex create mode 100644 lib/berrypod_web/page_editor_hook.ex create mode 100644 test/berrypod/pages/block_editor_test.exs create mode 100644 test/berrypod_web/page_editor_hook_test.exs diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index ff7954f..d4e1f2b 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -183,6 +183,11 @@ font-size: 0.75rem; } +.admin-btn-xs { + padding: 0.125rem 0.375rem; + font-size: 0.6875rem; +} + .admin-btn-icon { padding: 0.5rem; aspect-ratio: 1; @@ -469,6 +474,11 @@ font-size: 0.625rem; } +.admin-badge-warning { + background-color: color-mix(in oklch, var(--t-status-warning, #f59e0b) 15%, transparent); + color: var(--t-status-warning, #b45309); +} + /* ── Dropdown ── */ .admin-dropdown { @@ -1465,4 +1475,95 @@ border-style: dashed; } +/* ── Live editor layout (sidebar on shop pages) ── */ + +.page-editor-live { + display: flex; + min-height: 100vh; +} + +.page-editor-sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 360px; + background: var(--t-surface-base); + border-right: 1px solid var(--t-border-default); + overflow-y: auto; + z-index: 40; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); + padding: 1rem; + transition: transform 0.25s ease; + transform: translateX(0); +} + +/* Hidden sidebar — slides off-screen */ +[data-sidebar-open="false"] .page-editor-sidebar { + transform: translateX(-100%); + box-shadow: none; +} + +.page-editor-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.page-editor-sidebar-title { + font-size: 1rem; + font-weight: 600; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.page-editor-sidebar-actions { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; +} + +.page-editor-sidebar-dirty { + margin-bottom: 0.5rem; +} + +.page-editor-content { + flex: 1; + margin-left: 360px; + min-width: 0; + transition: margin-left 0.25s ease; +} + +/* Content goes full-width when sidebar is hidden */ +[data-sidebar-open="false"] .page-editor-content { + margin-left: 0; +} + +/* Clickable backdrop to dismiss the sidebar */ +.page-editor-backdrop { + position: fixed; + inset: 0; + z-index: 39; + background: rgba(0, 0, 0, 0.15); + cursor: pointer; +} + +/* Mobile: sidebar overlays content, no margin push */ +@media (max-width: 63.99em) { + .page-editor-sidebar { + width: 85%; + max-width: 360px; + } + + .page-editor-content { + margin-left: 0; + } +} + } /* @layer admin */ diff --git a/lib/berrypod/pages/block_editor.ex b/lib/berrypod/pages/block_editor.ex new file mode 100644 index 0000000..7d71b5a --- /dev/null +++ b/lib/berrypod/pages/block_editor.ex @@ -0,0 +1,255 @@ +defmodule Berrypod.Pages.BlockEditor do + @moduledoc """ + Pure functions for block list manipulation. + + Used by both the admin page editor and the live page editor sidebar. + No side effects — takes a block list in, returns a block list out. + """ + + alias Berrypod.Pages.{BlockTypes, Defaults} + + # ── Block operations ───────────────────────────────────────────── + + def move_up(blocks, block_id) do + idx = Enum.find_index(blocks, &(&1["id"] == block_id)) + + if idx && idx > 0 do + block = Enum.at(blocks, idx) + name = block_display_name(block) + + new_blocks = + blocks + |> List.delete_at(idx) + |> List.insert_at(idx - 1, block) + + {:ok, new_blocks, "#{name} moved to position #{idx}"} + else + :noop + end + end + + def move_down(blocks, block_id) do + idx = Enum.find_index(blocks, &(&1["id"] == block_id)) + + if idx && idx < length(blocks) - 1 do + block = Enum.at(blocks, idx) + name = block_display_name(block) + + new_blocks = + blocks + |> List.delete_at(idx) + |> List.insert_at(idx + 1, block) + + {:ok, new_blocks, "#{name} moved to position #{idx + 2}"} + else + :noop + end + end + + def remove_block(blocks, block_id) do + block = Enum.find(blocks, &(&1["id"] == block_id)) + name = block_display_name(block) + new_blocks = Enum.reject(blocks, &(&1["id"] == block_id)) + {:ok, new_blocks, "#{name} removed"} + end + + def duplicate_block(blocks, block_id) do + idx = Enum.find_index(blocks, &(&1["id"] == block_id)) + + if idx do + original = Enum.at(blocks, idx) + + copy = %{ + "id" => Defaults.generate_block_id(), + "type" => original["type"], + "settings" => original["settings"] || %{} + } + + name = block_display_name(original) + new_blocks = List.insert_at(blocks, idx + 1, copy) + {:ok, new_blocks, "#{name} duplicated"} + else + :noop + end + end + + def add_block(blocks, type) do + block_def = BlockTypes.get(type) + + if block_def do + default_settings = + block_def + |> Map.get(:settings_schema, []) + |> Enum.into(%{}, fn field -> {field.key, field.default} end) + + new_block = %{ + "id" => Defaults.generate_block_id(), + "type" => type, + "settings" => default_settings + } + + {:ok, blocks ++ [new_block], "#{block_def.name} added"} + else + :noop + end + end + + def update_settings(blocks, block_id, new_settings) do + block = Enum.find(blocks, &(&1["id"] == block_id)) + + if block do + schema = + case BlockTypes.get(block["type"]) do + %{settings_schema: s} -> s + _ -> [] + end + + coerced = coerce_settings(new_settings, schema) + + new_blocks = + Enum.map(blocks, fn b -> + if b["id"] == block_id do + Map.put(b, "settings", Map.merge(b["settings"] || %{}, coerced)) + else + b + end + end) + + {:ok, new_blocks} + else + :noop + end + end + + # ── Repeater operations ────────────────────────────────────────── + + def repeater_add(blocks, block_id, field_key) do + block = Enum.find(blocks, &(&1["id"] == block_id)) + + if block do + schema = BlockTypes.get(block["type"]) + field = Enum.find(schema.settings_schema, &(&1.key == field_key)) + empty_item = Map.new(field.item_schema, fn f -> {f.key, f.default} end) + + items = (block["settings"] || %{})[field_key] || [] + new_items = items ++ [empty_item] + new_blocks = update_block_field(blocks, block_id, field_key, new_items) + {:ok, new_blocks, "Item added"} + else + :noop + end + end + + def repeater_remove(blocks, block_id, field_key, index) do + block = Enum.find(blocks, &(&1["id"] == block_id)) + + if block do + items = (block["settings"] || %{})[field_key] || [] + new_items = List.delete_at(items, index) + new_blocks = update_block_field(blocks, block_id, field_key, new_items) + {:ok, new_blocks, "Item removed"} + else + :noop + end + end + + def repeater_move(blocks, block_id, field_key, index, direction) do + block = Enum.find(blocks, &(&1["id"] == block_id)) + + if block do + items = (block["settings"] || %{})[field_key] || [] + target = if direction == "up", do: index - 1, else: index + 1 + + if target >= 0 and target < length(items) do + item = Enum.at(items, index) + + new_items = + items + |> List.delete_at(index) + |> List.insert_at(target, item) + + new_blocks = update_block_field(blocks, block_id, field_key, new_items) + {:ok, new_blocks, "Item moved #{direction}"} + else + :noop + end + else + :noop + end + end + + # ── Helpers ────────────────────────────────────────────────────── + + def block_display_name(nil), do: "Block" + + def block_display_name(block) do + case BlockTypes.get(block["type"]) do + %{name: name} -> name + _ -> block["type"] + end + end + + def has_settings?(block) do + case BlockTypes.get(block["type"]) do + %{settings_schema: [_ | _]} -> true + _ -> false + end + end + + def settings_with_defaults(block) do + schema = + case BlockTypes.get(block["type"]) do + %{settings_schema: s} -> s + _ -> [] + end + + defaults = Map.new(schema, fn field -> {field.key, field.default} end) + Map.merge(defaults, block["settings"] || %{}) + end + + def coerce_settings(params, schema) do + type_map = Map.new(schema, fn field -> {field.key, field} end) + + Map.new(params, fn {key, value} -> + case type_map[key] do + %{type: :number, default: default} -> + {key, parse_number(value, default)} + + %{type: :repeater} -> + {key, indexed_map_to_list(value)} + + _ -> + {key, value} + end + end) + end + + def parse_number(value, default) when is_binary(value) do + case Integer.parse(value) do + {n, ""} -> n + _ -> default + end + end + + def parse_number(value, _default) when is_integer(value), do: value + def parse_number(_value, default), do: default + + defp update_block_field(blocks, block_id, field_key, new_value) do + Enum.map(blocks, fn b -> + if b["id"] == block_id do + settings = Map.put(b["settings"] || %{}, field_key, new_value) + Map.put(b, "settings", settings) + else + b + end + end) + end + + defp indexed_map_to_list(map) when is_map(map) do + map + |> Enum.sort_by(fn {k, _v} -> String.to_integer(k) end) + |> Enum.map(fn {_k, v} -> v end) + end + + defp indexed_map_to_list(value), do: value +end diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex index c87552a..1f6219c 100644 --- a/lib/berrypod/pages/block_types.ex +++ b/lib/berrypod/pages/block_types.ex @@ -233,8 +233,9 @@ defmodule Berrypod.Pages.BlockTypes do "content_body" => %{ name: "Page content", icon: "hero-document-text", - allowed_on: ["about", "delivery", "privacy", "terms"], + allowed_on: :all, settings_schema: [ + %SettingsField{key: "content", label: "Content", type: :textarea, default: ""}, %SettingsField{key: "image_src", label: "Image", type: :text, default: ""}, %SettingsField{key: "image_alt", label: "Image alt text", type: :text, default: ""} ] diff --git a/lib/berrypod_web/components/block_editor_components.ex b/lib/berrypod_web/components/block_editor_components.ex new file mode 100644 index 0000000..beda210 --- /dev/null +++ b/lib/berrypod_web/components/block_editor_components.ex @@ -0,0 +1,405 @@ +defmodule BerrypodWeb.BlockEditorComponents do + @moduledoc """ + Shared UI components for the block editor. + + Used by both the admin page editor (`Admin.Pages.Editor`) and the + live page editor sidebar (`PageEditorHook` + `PageRenderer`). + + All components accept an `event_prefix` attr to namespace phx-click + events. The admin editor uses `""` (default), the live sidebar uses + `"editor_"`. + """ + + use Phoenix.Component + + import BerrypodWeb.CoreComponents, only: [icon: 1] + + alias Berrypod.Pages.{BlockEditor, BlockTypes} + + # ── Block card ───────────────────────────────────────────────── + + attr :block, :map, required: true + attr :idx, :integer, required: true + attr :total, :integer, required: true + attr :expanded, :any, required: true + attr :event_prefix, :string, default: "" + + def block_card(assigns) do + block_type = BlockTypes.get(assigns.block["type"]) + has_settings = BlockEditor.has_settings?(assigns.block) + expanded = MapSet.member?(assigns.expanded, assigns.block["id"]) + + assigns = + assigns + |> assign(:block_type, block_type) + |> assign(:has_settings, has_settings) + |> assign(:is_expanded, expanded) + + ~H""" +
+
+ {@idx + 1} + + + <.icon name={(@block_type && @block_type.icon) || "hero-puzzle-piece"} class="size-5" /> + + + + {(@block_type && @block_type.name) || @block["type"]} + + + + + + + + + +
+ + <.block_settings_form + :if={@is_expanded} + block={@block} + schema={@block_type.settings_schema} + event_prefix={@event_prefix} + /> +
+ """ + end + + # ── Settings form ────────────────────────────────────────────── + + attr :block, :map, required: true + attr :schema, :list, required: true + attr :event_prefix, :string, default: "" + + def block_settings_form(assigns) do + settings = BlockEditor.settings_with_defaults(assigns.block) + assigns = assign(assigns, :settings, settings) + + ~H""" +
+
+ + +
+ <.block_field + :for={field <- @schema} + field={field} + value={@settings[field.key]} + block_id={@block["id"]} + event_prefix={@event_prefix} + /> +
+
+
+ """ + end + + # ── Field renderers ──────────────────────────────────────────── + + attr :field, :any, required: true + attr :value, :any, required: true + attr :block_id, :string, required: true + attr :event_prefix, :string, default: "" + + def block_field(%{field: %{type: :select}} = assigns) do + ~H""" +
+ +
+ """ + end + + def block_field(%{field: %{type: :textarea}} = assigns) do + ~H""" +
+ +
+ """ + end + + def block_field(%{field: %{type: :number}} = assigns) do + ~H""" +
+ +
+ """ + end + + def block_field(%{field: %{type: :repeater}} = assigns) do + items = if is_list(assigns.value), do: assigns.value, else: [] + item_count = length(items) + + assigns = + assigns + |> assign(:items, items) + |> assign(:item_count, item_count) + + ~H""" +
+ {@field.label} + +
    +
  1. +
    + + Item {idx + 1}: {item["label"] || item[:label] || "New item"} + + +
    + <.repeater_item_field + :for={sub_field <- @field.item_schema} + sub_field={sub_field} + item={item} + field_key={@field.key} + block_id={@block_id} + index={idx} + event_prefix={@event_prefix} + /> +
    + +
    + + + +
    +
    +
  2. +
+ + +
+ """ + end + + def block_field(assigns) do + ~H""" +
+ +
+ """ + end + + # ── Repeater item field ──────────────────────────────────────── + + attr :sub_field, :any, required: true + attr :item, :map, required: true + attr :field_key, :string, required: true + attr :block_id, :string, required: true + attr :index, :integer, required: true + attr :event_prefix, :string, default: "" + + def repeater_item_field(assigns) do + value = assigns.item[assigns.sub_field.key] || "" + assigns = assign(assigns, :value, value) + + ~H""" + + """ + end + + # ── Block picker ─────────────────────────────────────────────── + + attr :allowed_blocks, :any, required: true + attr :filter, :string, required: true + attr :event_prefix, :string, default: "" + + def block_picker(assigns) do + filter = String.downcase(assigns.filter) + + filtered = + assigns.allowed_blocks + |> Enum.filter(fn {_type, def} -> + filter == "" or String.contains?(String.downcase(def.name), filter) + end) + |> Enum.sort_by(fn {_type, def} -> def.name end) + + assigns = assign(assigns, :filtered_blocks, filtered) + + ~H""" +
+
+
+

Add a block

+ +
+ + + +
+ + +

+ No matching blocks. +

+
+
+
+ """ + end +end diff --git a/lib/berrypod_web/components/layouts/shop_root.html.heex b/lib/berrypod_web/components/layouts/shop_root.html.heex index 14f63b1..ac3e4e7 100644 --- a/lib/berrypod_web/components/layouts/shop_root.html.heex +++ b/lib/berrypod_web/components/layouts/shop_root.html.heex @@ -63,6 +63,9 @@ @layer properties, reset, primitives, tokens, theme, base, components, layout, utilities, overrides; + <%= if assigns[:current_scope] && @current_scope.user do %> + + <% end %> diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex index 52086e8..ac15a3a 100644 --- a/lib/berrypod_web/components/shop_components/layout.ex +++ b/lib/berrypod_web/components/shop_components/layout.ex @@ -51,7 +51,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do @layout_keys ~w(theme_settings logo_image header_image mode cart_items cart_count cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin search_query search_results search_open categories shipping_estimate - country_code available_countries)a + country_code available_countries editing editor_current_path editor_sidebar_open)a @doc """ Extracts the assigns relevant to `shop_layout` from a full assigns map. @@ -86,6 +86,9 @@ defmodule BerrypodWeb.ShopComponents.Layout do attr :active_page, :string, required: true attr :error_page, :boolean, default: false attr :is_admin, :boolean, default: false + attr :editing, :boolean, default: false + attr :editor_current_path, :string, default: nil + attr :editor_sidebar_open, :boolean, default: true attr :search_query, :string, default: "" attr :search_results, :list, default: [] attr :search_open, :boolean, default: false @@ -117,6 +120,9 @@ defmodule BerrypodWeb.ShopComponents.Layout do mode={@mode} cart_count={@cart_count} is_admin={@is_admin} + editing={@editing} + editor_current_path={@editor_current_path} + editor_sidebar_open={@editor_sidebar_open} /> {render_slot(@inner_block)} @@ -685,6 +691,9 @@ defmodule BerrypodWeb.ShopComponents.Layout do attr :mode, :atom, default: :live attr :cart_count, :integer, default: 0 attr :is_admin, :boolean, default: false + attr :editing, :boolean, default: false + attr :editor_current_path, :string, default: nil + attr :editor_sidebar_open, :boolean, default: true def shop_header(assigns) do ~H""" @@ -729,6 +738,23 @@ defmodule BerrypodWeb.ShopComponents.Layout do
+ <%!-- Pencil icon: enters edit mode, or re-opens sidebar if already editing --%> + <.link + :if={@is_admin && !@editing && @editor_current_path} + patch={"#{@editor_current_path}?edit=true"} + class="header-icon-btn" + aria-label="Edit page" + > + <.edit_pencil_svg /> + + <.link :if={@is_admin} href="/admin" @@ -933,6 +959,25 @@ defmodule BerrypodWeb.ShopComponents.Layout do """ end + defp edit_pencil_svg(assigns) do + ~H""" + + + + """ + end + defp open_cart_drawer_js do Phoenix.LiveView.JS.push("open_cart_drawer") end diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index c4153f4..bf81a67 100644 --- a/lib/berrypod_web/live/admin/pages/editor.ex +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -2,10 +2,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do use BerrypodWeb, :live_view alias Berrypod.{Media, Pages} - alias Berrypod.Pages.{BlockTypes, Defaults} + alias Berrypod.Pages.{BlockEditor, BlockTypes} 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) @@ -35,97 +37,125 @@ defmodule BerrypodWeb.Admin.Pages.Editor do |> assign(:header_image, Media.get_header())} end + # ── Block manipulation events ──────────────────────────────────── + @impl true def handle_event("move_up", %{"id" => block_id}, socket) do - blocks = socket.assigns.blocks - idx = Enum.find_index(blocks, &(&1["id"] == block_id)) + case BlockEditor.move_up(socket.assigns.blocks, block_id) do + {:ok, new_blocks, message} -> + {:noreply, apply_mutation(socket, new_blocks, message)} - if idx && idx > 0 do - block = Enum.at(blocks, idx) - block_name = block_display_name(block) - new_pos = idx - - new_blocks = - blocks - |> List.delete_at(idx) - |> List.insert_at(idx - 1, block) - - {:noreply, - socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true) - |> assign(:live_region_message, "#{block_name} moved to position #{new_pos}")} - else - {:noreply, socket} + :noop -> + {:noreply, socket} end end def handle_event("move_down", %{"id" => block_id}, socket) do - blocks = socket.assigns.blocks - idx = Enum.find_index(blocks, &(&1["id"] == block_id)) + case BlockEditor.move_down(socket.assigns.blocks, block_id) do + {:ok, new_blocks, message} -> + {:noreply, apply_mutation(socket, new_blocks, message)} - if idx && idx < length(blocks) - 1 do - block = Enum.at(blocks, idx) - block_name = block_display_name(block) - new_pos = idx + 2 - - new_blocks = - blocks - |> List.delete_at(idx) - |> List.insert_at(idx + 1, block) - - {:noreply, - socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true) - |> assign(:live_region_message, "#{block_name} moved to position #{new_pos}")} - else - {:noreply, socket} + :noop -> + {:noreply, socket} end end def handle_event("remove_block", %{"id" => block_id}, socket) do - block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) - block_name = block_display_name(block) - new_blocks = Enum.reject(socket.assigns.blocks, &(&1["id"] == block_id)) - - {:noreply, - socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true) - |> assign(:live_region_message, "#{block_name} removed")} + {: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 - blocks = socket.assigns.blocks - idx = Enum.find_index(blocks, &(&1["id"] == block_id)) + case BlockEditor.duplicate_block(socket.assigns.blocks, block_id) do + {:ok, new_blocks, message} -> + {:noreply, apply_mutation(socket, new_blocks, message)} - if idx do - original = Enum.at(blocks, idx) - - copy = %{ - "id" => Defaults.generate_block_id(), - "type" => original["type"], - "settings" => original["settings"] || %{} - } - - block_name = block_display_name(original) - new_blocks = List.insert_at(blocks, idx + 1, copy) - - {:noreply, - socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true) - |> assign(:live_region_message, "#{block_name} duplicated")} - else - {:noreply, socket} + :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 + + # ── 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 = block_display_name(block) + block_name = BlockEditor.block_display_name(block) {new_expanded, action} = if MapSet.member?(expanded, block_id) do @@ -140,122 +170,6 @@ defmodule BerrypodWeb.Admin.Pages.Editor do |> assign(:live_region_message, "#{block_name} settings #{action}")} end - def handle_event("update_block_settings", params, socket) do - block_id = params["block_id"] - new_settings = params["block_settings"] || %{} - - # Find the block and its schema to coerce types - block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) - - if block do - schema = - case BlockTypes.get(block["type"]) do - %{settings_schema: s} -> s - _ -> [] - end - - coerced = coerce_settings(new_settings, schema) - - new_blocks = - Enum.map(socket.assigns.blocks, fn b -> - if b["id"] == block_id do - Map.put(b, "settings", Map.merge(b["settings"] || %{}, coerced)) - else - b - end - end) - - {:noreply, - socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true)} - else - {:noreply, socket} - end - end - - def handle_event("repeater_add", %{"block-id" => block_id, "field" => field_key}, socket) do - block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) - - if block do - schema = BlockTypes.get(block["type"]) - field = Enum.find(schema.settings_schema, &(&1.key == field_key)) - empty_item = Map.new(field.item_schema, fn f -> {f.key, f.default} end) - - items = (block["settings"] || %{})[field_key] || [] - new_items = items ++ [empty_item] - - new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items) - - {:noreply, - socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true) - |> assign(:live_region_message, "Item added")} - else - {:noreply, socket} - end - end - - def handle_event( - "repeater_remove", - %{"block-id" => block_id, "field" => field_key, "index" => index_str}, - socket - ) do - block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) - - if block do - index = String.to_integer(index_str) - items = (block["settings"] || %{})[field_key] || [] - new_items = List.delete_at(items, index) - - new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items) - - {:noreply, - socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true) - |> assign(:live_region_message, "Item removed")} - else - {:noreply, socket} - end - end - - def handle_event( - "repeater_move", - %{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir}, - socket - ) do - block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) - - if block do - index = String.to_integer(index_str) - items = (block["settings"] || %{})[field_key] || [] - target = if dir == "up", do: index - 1, else: index + 1 - - if target >= 0 and target < length(items) do - item = Enum.at(items, index) - - new_items = - items - |> List.delete_at(index) - |> List.insert_at(target, item) - - new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items) - - {:noreply, - socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true) - |> assign(:live_region_message, "Item moved #{dir}")} - else - {:noreply, socket} - end - else - {:noreply, socket} - end - end - def handle_event("show_picker", _params, socket) do {:noreply, socket @@ -271,33 +185,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do {:noreply, assign(socket, :picker_filter, value)} end - def handle_event("add_block", %{"type" => type}, socket) do - block_def = BlockTypes.get(type) - - if block_def do - # Build default settings from schema - default_settings = - block_def - |> Map.get(:settings_schema, []) - |> Enum.into(%{}, fn field -> {field.key, field.default} end) - - new_block = %{ - "id" => Defaults.generate_block_id(), - "type" => type, - "settings" => default_settings - } - - {:noreply, - socket - |> assign(:blocks, socket.assigns.blocks ++ [new_block]) - |> assign(:dirty, true) - |> assign(:show_picker, false) - |> assign(:live_region_message, "#{block_def.name} added")} - else - {:noreply, socket} - 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 @@ -325,9 +218,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do |> put_flash(:info, "Page reset to defaults")} end - def handle_event("toggle_preview", _params, socket) do - {:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)} - end + # ── Render ─────────────────────────────────────────────────────── @impl true def render(assigns) do @@ -439,7 +330,6 @@ defmodule BerrypodWeb.Admin.Pages.Editor do # ── 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 = @@ -554,427 +444,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do |> Enum.reject(&is_nil/1) end - # ── Block card ───────────────────────────────────────────────────── + # ── Helpers ────────────────────────────────────────────────────── - defp block_card(assigns) do - block_type = BlockTypes.get(assigns.block["type"]) - has_settings = has_settings?(assigns.block) - expanded = MapSet.member?(assigns.expanded, assigns.block["id"]) - - assigns = - assigns - |> assign(:block_type, block_type) - |> assign(:has_settings, has_settings) - |> assign(:is_expanded, expanded) - - ~H""" -
-
- {@idx + 1} - - - <.icon name={(@block_type && @block_type.icon) || "hero-puzzle-piece"} class="size-5" /> - - - - {(@block_type && @block_type.name) || @block["type"]} - - - - - - - - - -
- - <.block_settings_form - :if={@is_expanded} - block={@block} - schema={@block_type.settings_schema} - /> -
- """ - end - - defp block_settings_form(assigns) do - settings = settings_with_defaults(assigns.block) - assigns = assign(assigns, :settings, settings) - - ~H""" -
-
- - -
- <.block_field - :for={field <- @schema} - field={field} - value={@settings[field.key]} - block_id={@block["id"]} - /> -
-
-
- """ - end - - defp block_field(%{field: %{type: :select}} = assigns) do - ~H""" -
- -
- """ - end - - defp block_field(%{field: %{type: :textarea}} = assigns) do - ~H""" -
- -
- """ - end - - defp block_field(%{field: %{type: :number}} = assigns) do - ~H""" -
- -
- """ - end - - defp block_field(%{field: %{type: :repeater}} = assigns) do - items = if is_list(assigns.value), do: assigns.value, else: [] - item_count = length(items) - - assigns = - assigns - |> assign(:items, items) - |> assign(:item_count, item_count) - - ~H""" -
- {@field.label} - -
    -
  1. -
    - - Item {idx + 1}: {item["label"] || item[:label] || "New item"} - - -
    - <.repeater_item_field - :for={sub_field <- @field.item_schema} - sub_field={sub_field} - item={item} - field_key={@field.key} - block_id={@block_id} - index={idx} - /> -
    - -
    - - - -
    -
    -
  2. -
- - -
- """ - end - - defp repeater_item_field(assigns) do - value = assigns.item[assigns.sub_field.key] || "" - - assigns = assign(assigns, :value, value) - - ~H""" - - """ - end - - defp block_field(assigns) do - ~H""" -
- -
- """ - end - - defp block_picker(assigns) do - filter = String.downcase(assigns.filter) - - filtered = - assigns.allowed_blocks - |> Enum.filter(fn {_type, def} -> - filter == "" or String.contains?(String.downcase(def.name), filter) - end) - |> Enum.sort_by(fn {_type, def} -> def.name end) - - assigns = assign(assigns, :filtered_blocks, filtered) - - ~H""" -
-
-
-

Add a block

- -
- - - -
- - -

- No matching blocks. -

-
-
-
- """ - end - - defp block_display_name(nil), do: "Block" - - defp block_display_name(block) do - case BlockTypes.get(block["type"]) do - %{name: name} -> name - _ -> block["type"] - end - end - - defp coerce_settings(params, schema) do - type_map = Map.new(schema, fn field -> {field.key, field} end) - - Map.new(params, fn {key, value} -> - case type_map[key] do - %{type: :number, default: default} -> - {key, parse_number(value, default)} - - %{type: :repeater} -> - {key, indexed_map_to_list(value)} - - _ -> - {key, value} - end - end) - end - - defp indexed_map_to_list(map) when is_map(map) do - map - |> Enum.sort_by(fn {k, _v} -> String.to_integer(k) end) - |> Enum.map(fn {_k, v} -> v end) - end - - defp indexed_map_to_list(value), do: value - - defp update_block_field(blocks, block_id, field_key, new_value) do - Enum.map(blocks, fn b -> - if b["id"] == block_id do - settings = Map.put(b["settings"] || %{}, field_key, new_value) - Map.put(b, "settings", settings) - else - b - end - end) - end - - defp parse_number(value, default) when is_binary(value) do - case Integer.parse(value) do - {n, ""} -> n - _ -> default - end - end - - defp parse_number(value, _default) when is_integer(value), do: value - defp parse_number(_value, default), do: default - - defp has_settings?(block) do - case BlockTypes.get(block["type"]) do - %{settings_schema: [_ | _]} -> true - _ -> false - end - end - - defp settings_with_defaults(block) do - schema = - case BlockTypes.get(block["type"]) do - %{settings_schema: s} -> s - _ -> [] - end - - defaults = Map.new(schema, fn field -> {field.key, field.default} end) - Map.merge(defaults, block["settings"] || %{}) + defp apply_mutation(socket, new_blocks, message) do + socket + |> assign(:blocks, new_blocks) + |> assign(:dirty, true) + |> assign(:live_region_message, message) end end diff --git a/lib/berrypod_web/page_editor_hook.ex b/lib/berrypod_web/page_editor_hook.ex new file mode 100644 index 0000000..5f3196b --- /dev/null +++ b/lib/berrypod_web/page_editor_hook.ex @@ -0,0 +1,360 @@ +defmodule BerrypodWeb.PageEditorHook do + @moduledoc """ + LiveView on_mount hook for the live page editor sidebar. + + Mounted in the public_shop live_session. When an admin visits any shop + page with `?edit=true` in the URL, this hook activates editing mode: + loads a working copy of the page's blocks, attaches event handlers for + block manipulation, and sets assigns that trigger the editor sidebar + in `PageRenderer.render_page/1`. + + Non-admin users are unaffected — the hook just assigns `editing: false`. + + ## Actions + + - `:mount_page_editor` — sets up editing assigns and attaches hooks + """ + + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [attach_hook: 4, put_flash: 3, push_navigate: 2] + + alias Berrypod.Pages + alias Berrypod.Pages.{BlockEditor, BlockTypes} + + def on_mount(:mount_page_editor, _params, _session, socket) do + socket = + socket + |> assign(:editing, false) + |> assign(:editing_blocks, nil) + |> assign(:editor_dirty, false) + |> assign(:editor_expanded, MapSet.new()) + |> assign(:editor_show_picker, false) + |> assign(:editor_picker_filter, "") + |> assign(:editor_allowed_blocks, nil) + |> assign(:editor_live_region_message, nil) + |> assign(:editor_current_path, nil) + |> assign(:editor_sidebar_open, true) + |> attach_hook(:editor_params, :handle_params, &handle_editor_params/3) + |> attach_hook(:editor_events, :handle_event, &handle_editor_event/3) + |> attach_hook(:editor_info, :handle_info, &handle_editor_info/2) + + {:cont, socket} + end + + # ── handle_params: detect ?edit=true ───────────────────────────── + + defp handle_editor_params(_params, uri, socket) do + parsed = URI.parse(uri) + query = URI.decode_query(parsed.query || "") + wants_edit = query["edit"] == "true" + + # Always store the current path for the edit button and "done" navigation + socket = assign(socket, :editor_current_path, parsed.path) + + cond do + wants_edit and socket.assigns.is_admin and socket.assigns[:page] -> + # Page already loaded — enter edit mode and halt (no need for module handle_params) + {:halt, enter_edit_mode(socket)} + + wants_edit and socket.assigns.is_admin -> + # Page not loaded yet (e.g. Shop.Content loads in handle_params), + # defer initialisation until after the LiveView sets @page + send(self(), :editor_deferred_init) + {:cont, assign(socket, :editing, true)} + + socket.assigns.editing and not wants_edit -> + # Exiting edit mode — halt since we've handled the transition + {:halt, exit_edit_mode(socket)} + + true -> + {:cont, socket} + end + end + + # ── handle_info: deferred init ─────────────────────────────────── + + defp handle_editor_info(:editor_deferred_init, socket) do + if socket.assigns.editing and is_nil(socket.assigns.editing_blocks) and socket.assigns[:page] do + {:halt, enter_edit_mode(socket)} + else + {:cont, socket} + end + end + + defp handle_editor_info(_msg, socket), do: {:cont, socket} + + # ── handle_event: editor_* events ──────────────────────────────── + + defp handle_editor_event("editor_" <> action, params, socket) do + if socket.assigns.editing do + handle_editor_action(action, params, socket) + else + {:cont, socket} + end + end + + defp handle_editor_event(_event, _params, socket), do: {:cont, socket} + + # ── Block manipulation actions ─────────────────────────────────── + + defp handle_editor_action("move_up", %{"id" => id}, socket) do + case BlockEditor.move_up(socket.assigns.editing_blocks, id) do + {:ok, new_blocks, message} -> + {:halt, apply_mutation(socket, new_blocks, message, :reorder)} + + :noop -> + {:halt, socket} + end + end + + defp handle_editor_action("move_down", %{"id" => id}, socket) do + case BlockEditor.move_down(socket.assigns.editing_blocks, id) do + {:ok, new_blocks, message} -> + {:halt, apply_mutation(socket, new_blocks, message, :reorder)} + + :noop -> + {:halt, socket} + end + end + + defp handle_editor_action("remove_block", %{"id" => id}, socket) do + {:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.editing_blocks, id) + {:halt, apply_mutation(socket, new_blocks, message, :content)} + end + + defp handle_editor_action("duplicate_block", %{"id" => id}, socket) do + case BlockEditor.duplicate_block(socket.assigns.editing_blocks, id) do + {:ok, new_blocks, message} -> + {:halt, apply_mutation(socket, new_blocks, message, :content)} + + :noop -> + {:halt, socket} + end + end + + defp handle_editor_action("add_block", %{"type" => type}, socket) do + case BlockEditor.add_block(socket.assigns.editing_blocks, type) do + {:ok, new_blocks, message} -> + socket = + socket + |> assign(:editing_blocks, new_blocks) + |> assign(:editor_dirty, true) + |> assign(:editor_show_picker, false) + |> assign(:editor_live_region_message, message) + |> reload_block_data(new_blocks) + + {:halt, socket} + + :noop -> + {:halt, socket} + end + end + + defp handle_editor_action("update_block_settings", params, socket) do + block_id = params["block_id"] + new_settings = params["block_settings"] || %{} + + case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, new_settings) do + {:ok, new_blocks} -> + socket = + socket + |> assign(:editing_blocks, new_blocks) + |> assign(:editor_dirty, true) + |> reload_block_data(new_blocks) + + {:halt, socket} + + :noop -> + {:halt, socket} + end + end + + # ── Repeater actions ───────────────────────────────────────────── + + defp handle_editor_action( + "repeater_add", + %{"block-id" => block_id, "field" => field_key}, + socket + ) do + case BlockEditor.repeater_add(socket.assigns.editing_blocks, block_id, field_key) do + {:ok, new_blocks, message} -> + {:halt, apply_mutation(socket, new_blocks, message, :content)} + + :noop -> + {:halt, socket} + end + end + + defp handle_editor_action( + "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.editing_blocks, block_id, field_key, index) do + {:ok, new_blocks, message} -> + {:halt, apply_mutation(socket, new_blocks, message, :content)} + + :noop -> + {:halt, socket} + end + end + + defp handle_editor_action( + "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.editing_blocks, + block_id, + field_key, + index, + dir + ) do + {:ok, new_blocks, message} -> + {:halt, apply_mutation(socket, new_blocks, message, :reorder)} + + :noop -> + {:halt, socket} + end + end + + # ── UI state actions ───────────────────────────────────────────── + + defp handle_editor_action("toggle_expand", %{"id" => block_id}, socket) do + expanded = socket.assigns.editor_expanded + block = Enum.find(socket.assigns.editing_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 + + {:halt, + socket + |> assign(:editor_expanded, new_expanded) + |> assign(:editor_live_region_message, "#{block_name} settings #{action}")} + end + + defp handle_editor_action("toggle_sidebar", _params, socket) do + {:halt, assign(socket, :editor_sidebar_open, !socket.assigns.editor_sidebar_open)} + end + + defp handle_editor_action("show_picker", _params, socket) do + {:halt, + socket + |> assign(:editor_show_picker, true) + |> assign(:editor_picker_filter, "")} + end + + defp handle_editor_action("hide_picker", _params, socket) do + {:halt, assign(socket, :editor_show_picker, false)} + end + + defp handle_editor_action("filter_picker", %{"value" => value}, socket) do + {:halt, assign(socket, :editor_picker_filter, value)} + end + + # ── Page actions ───────────────────────────────────────────────── + + defp handle_editor_action("save", _params, socket) do + %{page: page, editing_blocks: blocks} = socket.assigns + + case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do + {:ok, _saved_page} -> + updated_page = Pages.get_page(page.slug) + + socket = + socket + |> assign(:page, updated_page) + |> assign(:editing_blocks, updated_page.blocks) + |> assign(:editor_dirty, false) + |> put_flash(:info, "Page saved") + + {:halt, socket} + + {:error, _changeset} -> + {:halt, put_flash(socket, :error, "Failed to save page")} + end + end + + defp handle_editor_action("reset_defaults", _params, socket) do + slug = socket.assigns.page.slug + :ok = Pages.reset_page(slug) + page = Pages.get_page(slug) + + socket = + socket + |> assign(:page, page) + |> assign(:editing_blocks, page.blocks) + |> assign(:editor_dirty, false) + |> reload_block_data(page.blocks) + |> put_flash(:info, "Page reset to defaults") + + {:halt, socket} + end + + defp handle_editor_action("done", _params, socket) do + path = socket.assigns.editor_current_path || "/" + {:halt, push_navigate(socket, to: path)} + end + + # Catch-all for unknown editor actions + defp handle_editor_action(_action, _params, socket), do: {:halt, socket} + + # ── Private helpers ────────────────────────────────────────────── + + defp enter_edit_mode(socket) do + page = socket.assigns.page + allowed = BlockTypes.allowed_for(page.slug) + + socket + |> assign(:editing, true) + |> assign(:editing_blocks, page.blocks) + |> assign(:editor_dirty, false) + |> assign(:editor_expanded, MapSet.new()) + |> assign(:editor_show_picker, false) + |> assign(:editor_picker_filter, "") + |> assign(:editor_allowed_blocks, allowed) + |> assign(:editor_live_region_message, nil) + |> assign(:editor_sidebar_open, true) + end + + defp exit_edit_mode(socket) do + socket + |> assign(:editing, false) + |> assign(:editing_blocks, nil) + |> assign(:editor_dirty, false) + |> assign(:editor_expanded, MapSet.new()) + |> assign(:editor_show_picker, false) + |> assign(:editor_picker_filter, "") + |> assign(:editor_allowed_blocks, nil) + |> assign(:editor_live_region_message, nil) + |> assign(:editor_sidebar_open, true) + end + + defp apply_mutation(socket, new_blocks, message, type) do + socket = + socket + |> assign(:editing_blocks, new_blocks) + |> assign(:editor_dirty, true) + |> assign(:editor_live_region_message, message) + + case type do + :content -> reload_block_data(socket, new_blocks) + :reorder -> socket + end + end + + defp reload_block_data(socket, blocks) do + extra = Pages.load_block_data(blocks, socket.assigns) + Enum.reduce(extra, socket, fn {key, value}, acc -> assign(acc, key, value) end) + end +end diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index d935320..03172be 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -15,6 +15,9 @@ defmodule BerrypodWeb.PageRenderer do router: BerrypodWeb.Router, statics: BerrypodWeb.static_paths() + import BerrypodWeb.BlockEditorComponents + import BerrypodWeb.CoreComponents, only: [icon: 1] + alias Berrypod.Cart # ── Public API ────────────────────────────────────────────────── @@ -24,8 +27,19 @@ defmodule BerrypodWeb.PageRenderer do Expects `@page` (with `:slug` and `:blocks`) plus all the standard layout assigns (theme_settings, cart_items, etc.). + + When `@editing` is true and `@editing_blocks` is set (admin using the + live page editor), wraps the page in a sidebar + content layout. """ def render_page(assigns) do + if assigns[:editing] && assigns[:editing_blocks] do + render_page_with_editor(assigns) + else + render_page_normal(assigns) + end + end + + defp render_page_normal(assigns) do assigns = assign(assigns, :block_assigns, block_assigns(assigns)) ~H""" @@ -43,6 +57,122 @@ defmodule BerrypodWeb.PageRenderer do """ end + defp render_page_with_editor(assigns) do + assigns = assign(assigns, :block_assigns, block_assigns(assigns)) + + ~H""" +
+ + + <%!-- Backdrop: tapping the page dismisses the sidebar --%> + + """ + end + # ── Block dispatch ────────────────────────────────────────────── defp render_block(%{block: %{"type" => "hero"}} = assigns) do @@ -429,11 +559,13 @@ defmodule BerrypodWeb.PageRenderer do defp render_block(%{block: %{"type" => "content_body"}} = assigns) do settings = assigns.block["settings"] || %{} + content = settings["content"] || "" assigns = assigns |> assign(:image_src, settings["image_src"]) |> assign(:image_alt, settings["image_alt"] || "") + |> assign(:content, content) ~H"""
@@ -449,7 +581,17 @@ defmodule BerrypodWeb.PageRenderer do
<% end %> - <.rich_text blocks={assigns[:content_blocks] || []} /> + <%= if @content != "" do %> + <%!-- Inline content from block settings — split on blank lines for paragraphs --%> +
+

+ {para} +

+
+ <% else %> + <%!-- Structured rich text from LiveView (content pages) --%> + <.rich_text blocks={assigns[:content_blocks] || []} /> + <% end %>
""" end diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 43b3ebb..e41f09d 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -70,7 +70,8 @@ defmodule BerrypodWeb.Router do {BerrypodWeb.ThemeHook, :require_site_live}, {BerrypodWeb.CartHook, :mount_cart}, {BerrypodWeb.SearchHook, :mount_search}, - {BerrypodWeb.AnalyticsHook, :track} + {BerrypodWeb.AnalyticsHook, :track}, + {BerrypodWeb.PageEditorHook, :mount_page_editor} ] do live "/", Shop.Home, :index live "/about", Shop.Content, :about diff --git a/test/berrypod/pages/block_editor_test.exs b/test/berrypod/pages/block_editor_test.exs new file mode 100644 index 0000000..d8aa06b --- /dev/null +++ b/test/berrypod/pages/block_editor_test.exs @@ -0,0 +1,187 @@ +defmodule Berrypod.Pages.BlockEditorTest do + use ExUnit.Case, async: true + + alias Berrypod.Pages.BlockEditor + + defp make_block(id, type, settings \\ %{}) do + %{"id" => id, "type" => type, "settings" => settings} + end + + defp three_blocks do + [ + make_block("a", "hero", %{"title" => "Welcome"}), + make_block("b", "featured_products"), + make_block("c", "image_text", %{"title" => "About us"}) + ] + end + + describe "move_up/2" do + test "moves a block up one position" do + {:ok, blocks, msg} = BlockEditor.move_up(three_blocks(), "b") + + assert [%{"id" => "b"}, %{"id" => "a"}, %{"id" => "c"}] = blocks + assert msg =~ "moved to position" + end + + test "returns :noop for the first block" do + assert :noop = BlockEditor.move_up(three_blocks(), "a") + end + + test "returns :noop for unknown id" do + assert :noop = BlockEditor.move_up(three_blocks(), "zzz") + end + end + + describe "move_down/2" do + test "moves a block down one position" do + {:ok, blocks, msg} = BlockEditor.move_down(three_blocks(), "b") + + assert [%{"id" => "a"}, %{"id" => "c"}, %{"id" => "b"}] = blocks + assert msg =~ "moved to position" + end + + test "returns :noop for the last block" do + assert :noop = BlockEditor.move_down(three_blocks(), "c") + end + end + + describe "remove_block/2" do + test "removes the specified block" do + {:ok, blocks, msg} = BlockEditor.remove_block(three_blocks(), "b") + + assert length(blocks) == 2 + refute Enum.any?(blocks, &(&1["id"] == "b")) + assert msg =~ "removed" + end + end + + describe "duplicate_block/2" do + test "inserts a copy after the original" do + {:ok, blocks, msg} = BlockEditor.duplicate_block(three_blocks(), "a") + + assert length(blocks) == 4 + assert Enum.at(blocks, 0)["id"] == "a" + # Copy is at index 1 with a new ID + copy = Enum.at(blocks, 1) + assert copy["type"] == "hero" + assert copy["id"] != "a" + assert copy["settings"] == %{"title" => "Welcome"} + assert msg =~ "duplicated" + end + + test "returns :noop for unknown id" do + assert :noop = BlockEditor.duplicate_block(three_blocks(), "zzz") + end + end + + describe "add_block/2" do + test "appends a new block of the given type" do + {:ok, blocks, msg} = BlockEditor.add_block(three_blocks(), "hero") + + assert length(blocks) == 4 + new = List.last(blocks) + assert new["type"] == "hero" + assert is_binary(new["id"]) + assert msg =~ "added" + end + + test "returns :noop for unknown block type" do + assert :noop = BlockEditor.add_block(three_blocks(), "nonexistent_block_type") + end + end + + describe "update_settings/3" do + test "merges new settings into the block" do + {:ok, blocks} = + BlockEditor.update_settings(three_blocks(), "a", %{"title" => "New title"}) + + hero = Enum.find(blocks, &(&1["id"] == "a")) + assert hero["settings"]["title"] == "New title" + end + + test "returns :noop for unknown block id" do + assert :noop = BlockEditor.update_settings(three_blocks(), "zzz", %{"title" => "Nope"}) + end + end + + describe "block_display_name/1" do + test "returns the block type name from registry" do + block = make_block("a", "hero") + assert BlockEditor.block_display_name(block) == "Hero banner" + end + + test "returns the raw type for unknown types" do + block = make_block("a", "weird_thing") + assert BlockEditor.block_display_name(block) == "weird_thing" + end + + test "handles nil" do + assert BlockEditor.block_display_name(nil) == "Block" + end + end + + describe "has_settings?/1" do + test "returns true for blocks with settings schema" do + assert BlockEditor.has_settings?(make_block("a", "hero")) + end + + test "returns false for blocks without settings" do + refute BlockEditor.has_settings?(make_block("a", "category_nav")) + end + end + + describe "settings_with_defaults/1" do + test "fills in default values for missing settings" do + block = make_block("a", "hero", %{}) + result = BlockEditor.settings_with_defaults(block) + + # Hero has a title field with a default + assert is_binary(result["title"]) + end + + test "preserves existing settings" do + block = make_block("a", "hero", %{"title" => "Custom"}) + result = BlockEditor.settings_with_defaults(block) + + assert result["title"] == "Custom" + end + end + + describe "coerce_settings/2" do + test "passes through string values" do + schema = [%{key: "title", type: :text, default: ""}] + result = BlockEditor.coerce_settings(%{"title" => "Hello"}, schema) + assert result["title"] == "Hello" + end + + test "parses number fields" do + schema = [%{key: "columns", type: :number, default: 4}] + result = BlockEditor.coerce_settings(%{"columns" => "3"}, schema) + assert result["columns"] == 3 + end + + test "falls back to default for invalid numbers" do + schema = [%{key: "columns", type: :number, default: 4}] + result = BlockEditor.coerce_settings(%{"columns" => "abc"}, schema) + assert result["columns"] == 4 + end + end + + describe "parse_number/2" do + test "parses valid integer strings" do + assert BlockEditor.parse_number("42", 0) == 42 + end + + test "returns integer values as-is" do + assert BlockEditor.parse_number(7, 0) == 7 + end + + test "returns default for non-numeric strings" do + assert BlockEditor.parse_number("nope", 5) == 5 + end + + test "returns default for nil" do + assert BlockEditor.parse_number(nil, 10) == 10 + end + end +end diff --git a/test/berrypod_web/page_editor_hook_test.exs b/test/berrypod_web/page_editor_hook_test.exs new file mode 100644 index 0000000..5e19af9 --- /dev/null +++ b/test/berrypod_web/page_editor_hook_test.exs @@ -0,0 +1,231 @@ +defmodule BerrypodWeb.PageEditorHookTest do + use BerrypodWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Berrypod.AccountsFixtures + import Berrypod.ProductsFixtures + + alias Berrypod.Pages + alias Berrypod.Pages.PageCache + + setup do + PageCache.invalidate_all() + user = user_fixture() + {:ok, _} = Berrypod.Settings.set_site_live(true) + + provider_conn = provider_connection_fixture() + + product = + product_fixture(%{ + provider_connection: provider_conn, + title: "Test Product", + category: "Test Category" + }) + + product_variant_fixture(%{product: product, title: "Standard", price: 1999}) + Berrypod.Products.recompute_cached_fields(product) + + %{user: user, product: product} + end + + describe "non-admin cannot access edit mode" do + test "visiting with ?edit=true shows normal page, no sidebar", %{conn: conn} do + {:ok, _view, html} = live(conn, "/?edit=true") + + refute html =~ "page-editor-sidebar" + refute html =~ "page-editor-live" + end + end + + describe "edit button visibility" do + test "admin sees edit pencil in shop header", %{conn: conn, user: user} do + conn = log_in_user(conn, user) + {:ok, _view, html} = live(conn, "/") + + assert html =~ "Edit page" + end + + test "non-admin does not see edit pencil", %{conn: conn} do + {:ok, _view, html} = live(conn, "/") + + refute html =~ "Edit page" + end + end + + describe "entering and exiting edit mode" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "admin enters edit mode with ?edit=true", %{conn: conn} do + {:ok, view, _html} = live(conn, "/?edit=true") + + assert has_element?(view, ".page-editor-sidebar") + assert has_element?(view, ".page-editor-content") + assert has_element?(view, ".block-card") + end + + test "sidebar shows the page title", %{conn: conn} do + {:ok, view, _html} = live(conn, "/?edit=true") + + assert has_element?(view, ".page-editor-sidebar-title", "Home page") + end + + test "done button exits edit mode", %{conn: conn} do + {:ok, view, _html} = live(conn, "/?edit=true") + + # editor_done uses push_navigate, which causes a redirect + {:error, {:live_redirect, %{to: "/"}}} = + view |> element("button[phx-click='editor_done']") |> render_click() + end + + test "toggle sidebar hides and shows via header pencil", %{conn: conn} do + {:ok, view, _html} = live(conn, "/?edit=true") + + # Sidebar starts open, pencil button in header is hidden + assert has_element?(view, "[data-sidebar-open='true']") + + refute has_element?( + view, + "button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']" + ) + + # Close the sidebar via the X button + view + |> element("button[phx-click='editor_toggle_sidebar'][aria-label='Close sidebar']") + |> render_click() + + assert has_element?(view, "[data-sidebar-open='false']") + # Pencil button appears in header to re-open + assert has_element?( + view, + "button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']" + ) + + # Re-open via pencil in header + view + |> element("button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']") + |> render_click() + + assert has_element?(view, "[data-sidebar-open='true']") + end + + test "clicking backdrop hides the sidebar", %{conn: conn} do + {:ok, view, _html} = live(conn, "/?edit=true") + + # Backdrop present when sidebar is open + assert has_element?(view, ".page-editor-backdrop") + + # Click backdrop to dismiss + view |> element(".page-editor-backdrop") |> render_click() + + assert has_element?(view, "[data-sidebar-open='false']") + # Backdrop gone when sidebar is hidden + refute has_element?(view, ".page-editor-backdrop") + end + end + + describe "block manipulation in edit mode" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "move block down reorders the list", %{conn: conn} do + {:ok, view, _html} = live(conn, "/?edit=true") + + # Home page default: hero is first block + first_card = view |> element(".block-card:first-child") + first_html = render(first_card) + assert first_html =~ "Hero" + + # Get the hero block's ID and move it down + blocks = Pages.get_page("home").blocks + hero_id = List.first(blocks)["id"] + + view + |> element("button[phx-click='editor_move_down'][phx-value-id='#{hero_id}']") + |> render_click() + + # After move, hero is no longer the first card + updated_first = view |> element(".block-card:first-child") |> render() + refute updated_first =~ "Hero" + end + + test "dirty indicator appears after changes", %{conn: conn} do + {:ok, view, _html} = live(conn, "/?edit=true") + + refute has_element?(view, ".admin-badge-warning", "Unsaved changes") + + # Move a block to trigger dirty state + blocks = Pages.get_page("home").blocks + second_id = Enum.at(blocks, 1)["id"] + + view + |> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']") + |> render_click() + + assert has_element?(view, ".admin-badge-warning", "Unsaved changes") + end + end + + describe "save and reset" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "save persists block changes", %{conn: conn} do + {:ok, view, _html} = live(conn, "/?edit=true") + + # Move a block to make changes + blocks = Pages.get_page("home").blocks + original_first_type = List.first(blocks)["type"] + second_id = Enum.at(blocks, 1)["id"] + + view + |> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']") + |> render_click() + + # Save + view |> element("button[phx-click='editor_save']") |> render_click() + + assert has_element?(view, "#shop-flash-info", "Page saved") + + # Verify persistence + updated = Pages.get_page("home") + refute List.first(updated.blocks)["type"] == original_first_type + end + + test "reset restores default blocks", %{conn: conn} do + # First, save a modified page + original = Pages.get_page("home") + reordered = Enum.reverse(original.blocks) + Pages.save_page("home", %{title: original.title, blocks: reordered}) + PageCache.invalidate_all() + + {:ok, view, _html} = live(conn, "/?edit=true") + + # Reset + view |> element("button[phx-click='editor_reset_defaults']") |> render_click() + + assert has_element?(view, "#shop-flash-info", "Page reset to defaults") + + # Verify the blocks are back to defaults + reset_page = Pages.get_page("home") + assert List.first(reset_page.blocks)["type"] == List.first(original.blocks)["type"] + end + end + + describe "content pages (deferred init)" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "editing works on about page via deferred init", %{conn: conn} do + {:ok, view, _html} = live(conn, "/about?edit=true") + + assert has_element?(view, ".page-editor-sidebar") + assert has_element?(view, ".page-editor-sidebar-title", "About") + assert has_element?(view, ".block-card") + end + end +end