diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 7b10760..7706229 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1346,16 +1346,55 @@ gap: 0.625rem; } -.block-settings-json { - font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace; - font-size: 0.75rem; - opacity: 0.7; +/* Repeater fields */ + +.repeater-field { + display: flex; + flex-direction: column; + gap: 0.5rem; } -.block-settings-hint { - font-size: 0.75rem; - color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); - margin-top: 0.25rem; +.repeater-items { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.repeater-item { + border: 1px solid var(--t-border-default); + border-radius: 0.375rem; + padding: 0.5rem; + + & fieldset { + border: none; + padding: 0; + margin: 0; + } +} + +.repeater-item-fields { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.repeater-item-controls { + display: flex; + gap: 0.125rem; + justify-content: flex-end; + margin-top: 0.375rem; +} + +.repeater-remove-btn { + color: var(--t-danger); +} + +.repeater-add-btn { + align-self: flex-start; + border-style: dashed; } } /* @layer admin */ diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex index 19663d6..c87552a 100644 --- a/lib/berrypod/pages/block_types.ex +++ b/lib/berrypod/pages/block_types.ex @@ -7,6 +7,7 @@ defmodule Berrypod.Pages.BlockTypes do is present on a page. """ + alias Berrypod.Pages.SettingsField alias Berrypod.Products alias Berrypod.Theme.PreviewData @@ -18,13 +19,23 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-megaphone", allowed_on: :all, settings_schema: [ - %{key: "title", label: "Title", type: :text, default: ""}, - %{key: "description", label: "Description", type: :textarea, default: ""}, - %{key: "cta_text", label: "Button text", type: :text, default: ""}, - %{key: "cta_href", label: "Button link", type: :text, default: ""}, - %{key: "secondary_cta_text", label: "Secondary button text", type: :text, default: ""}, - %{key: "secondary_cta_href", label: "Secondary button link", type: :text, default: ""}, - %{ + %SettingsField{key: "title", label: "Title", type: :text, default: ""}, + %SettingsField{key: "description", label: "Description", type: :textarea, default: ""}, + %SettingsField{key: "cta_text", label: "Button text", type: :text, default: ""}, + %SettingsField{key: "cta_href", label: "Button link", type: :text, default: ""}, + %SettingsField{ + key: "secondary_cta_text", + label: "Secondary button text", + type: :text, + default: "" + }, + %SettingsField{ + key: "secondary_cta_href", + label: "Secondary button link", + type: :text, + default: "" + }, + %SettingsField{ key: "variant", label: "Style", type: :select, @@ -38,23 +49,33 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-star", allowed_on: :all, settings_schema: [ - %{key: "title", label: "Title", type: :text, default: "Featured products"}, - %{key: "product_count", label: "Number of products", type: :number, default: 8}, - %{ + %SettingsField{ + key: "title", + label: "Title", + type: :text, + default: "Featured products" + }, + %SettingsField{ + key: "product_count", + label: "Number of products", + type: :number, + default: 8 + }, + %SettingsField{ key: "layout", label: "Layout", type: :select, options: ~w(section grid), default: "section" }, - %{ + %SettingsField{ key: "card_variant", label: "Card style", type: :select, options: ~w(featured default minimal compact), default: "featured" }, - %{ + %SettingsField{ key: "columns", label: "Columns", type: :select, @@ -69,11 +90,11 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-photo", allowed_on: :all, settings_schema: [ - %{key: "title", label: "Title", type: :text, default: ""}, - %{key: "description", label: "Description", type: :textarea, default: ""}, - %{key: "image_url", label: "Image URL", type: :text, default: ""}, - %{key: "link_text", label: "Link text", type: :text, default: ""}, - %{key: "link_href", label: "Link URL", type: :text, default: ""} + %SettingsField{key: "title", label: "Title", type: :text, default: ""}, + %SettingsField{key: "description", label: "Description", type: :textarea, default: ""}, + %SettingsField{key: "image_url", label: "Image URL", type: :text, default: ""}, + %SettingsField{key: "link_text", label: "Link text", type: :text, default: ""}, + %SettingsField{key: "link_href", label: "Link URL", type: :text, default: ""} ] }, "category_nav" => %{ @@ -99,8 +120,17 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-information-circle", allowed_on: :all, settings_schema: [ - %{key: "title", label: "Title", type: :text, default: ""}, - %{key: "items", label: "Items", type: :json, default: []} + %SettingsField{key: "title", label: "Title", type: :text, default: ""}, + %SettingsField{ + key: "items", + label: "Items", + type: :repeater, + default: [], + item_schema: [ + %SettingsField{key: "label", label: "Label", type: :text, default: ""}, + %SettingsField{key: "value", label: "Value", type: :text, default: ""} + ] + } ] }, "trust_badges" => %{ @@ -188,7 +218,7 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-envelope", allowed_on: ["contact"], settings_schema: [ - %{key: "email", label: "Email", type: :text, default: "hello@example.com"} + %SettingsField{key: "email", label: "Email", type: :text, default: "hello@example.com"} ] }, "order_tracking_card" => %{ @@ -205,8 +235,8 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-document-text", allowed_on: ["about", "delivery", "privacy", "terms"], settings_schema: [ - %{key: "image_src", label: "Image", type: :text, default: ""}, - %{key: "image_alt", label: "Image alt text", type: :text, 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/pages/settings_field.ex b/lib/berrypod/pages/settings_field.ex new file mode 100644 index 0000000..62eec25 --- /dev/null +++ b/lib/berrypod/pages/settings_field.ex @@ -0,0 +1,16 @@ +defmodule Berrypod.Pages.SettingsField do + @moduledoc "Defines a single field in a block's settings schema." + + @type field_type :: :text | :textarea | :number | :select | :repeater + @type t :: %__MODULE__{ + key: String.t(), + label: String.t(), + type: field_type(), + default: term(), + options: [String.t()] | nil, + item_schema: [t()] | nil + } + + @enforce_keys [:key, :label, :type] + defstruct [:key, :label, :type, default: nil, options: nil, item_schema: nil] +end diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index 31c975c..0c59a81 100644 --- a/lib/berrypod_web/live/admin/pages/editor.ex +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -2,7 +2,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do use BerrypodWeb, :live_view alias Berrypod.Pages - alias Berrypod.Pages.{BlockTypes, Defaults} + alias Berrypod.Pages.{BlockTypes, Defaults, SettingsField} @impl true def mount(%{"slug" => slug}, _session, socket) do @@ -162,6 +162,88 @@ defmodule BerrypodWeb.Admin.Pages.Editor do 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 @@ -470,34 +552,114 @@ defmodule BerrypodWeb.Admin.Pages.Editor do """ 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 + defp block_field(%{field: %{type: :repeater}} = assigns) do + items = if is_list(assigns.value), do: assigns.value, else: [] + item_count = length(items) - assigns = assign(assigns, :json_str, json_str) + assigns = + assigns + |> assign(:items, items) + |> assign(:item_count, item_count) ~H""" -