defmodule BerrypodWeb.Admin.Pages.Editor do use BerrypodWeb, :live_view alias Berrypod.Pages alias Berrypod.Pages.{BlockTypes, Defaults} @impl true def mount(%{"slug" => slug}, _session, socket) do page = Pages.get_page(slug) allowed_blocks = BlockTypes.allowed_for(slug) {:ok, socket |> assign(:page_title, page.title) |> assign(:slug, slug) |> assign(:page_data, page) |> assign(:blocks, page.blocks) |> assign(:allowed_blocks, allowed_blocks) |> assign(:dirty, false) |> assign(:show_picker, false) |> assign(:picker_filter, "") |> assign(:expanded, MapSet.new()) |> assign(:live_region_message, nil)} end @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)) 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} 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)) 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} 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")} end def handle_event("duplicate_block", %{"id" => block_id}, socket) do blocks = socket.assigns.blocks 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"] || %{} } 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} end end 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) {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("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("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("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 end def handle_event("save", _params, socket) do %{slug: slug, blocks: blocks, page_data: page_data} = socket.assigns case Pages.save_page(slug, %{title: page_data.title, blocks: blocks}) do {:ok, _page} -> {:noreply, socket |> assign(:dirty, false) |> put_flash(:info, "Page saved")} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "Failed to save page")} end end def handle_event("reset_defaults", _params, socket) do slug = socket.assigns.slug :ok = Pages.reset_page(slug) page = Pages.get_page(slug) {:noreply, socket |> assign(:blocks, page.blocks) |> assign(:dirty, false) |> put_flash(:info, "Page reset to defaults")} end @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> <%!-- ARIA live region for screen reader announcements --%>
{if @live_region_message, do: @live_region_message}
<%!-- Unsaved changes indicator --%>

Unsaved changes

<%!-- 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 --%>
<%!-- Block picker modal --%> <.block_picker :if={@show_picker} allowed_blocks={@allowed_blocks} filter={@picker_filter} />
""" end 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: :json}} = assigns) do json_str = case assigns.value do val when is_list(val) or is_map(val) -> Jason.encode!(val, pretty: true) val when is_binary(val) -> val _ -> "[]" end assigns = assign(assigns, :json_str, json_str) ~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)} _ -> {key, value} 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"] || %{}) end end