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"""
<.link navigate={~p"/admin/pages"} class="text-sm font-normal text-base-content/60 hover:underline" > ← Pages <.header> {@page_data.title} <:actions> <.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 <%!-- ARIA live region for screen reader announcements --%>
{if @live_region_message, do: @live_region_message}
<%!-- Unsaved changes indicator --%>

Unsaved changes

<%!-- Editor pane --%>
<%!-- Block list --%>
<.block_card :for={{block, idx} <- Enum.with_index(@blocks)} block={block} idx={idx} total={length(@blocks)} expanded={@expanded} />

No blocks on this page yet.

<%!-- Add block button --%>
<%!-- Preview pane --%>
<.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} />
<%!-- 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} />
""" 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"""
""" 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