From 93ff66debcd3f9690875797b3fd18392fd5f8bba Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 28 Feb 2026 17:55:02 +0000 Subject: [PATCH] add legal page editor integration and media library polish Legal pages (privacy, delivery, terms) now auto-populate content from shop settings on mount, show auto-generated vs customised badges, and have a regenerate button. Theme editor gains alt text fields for logo, header, and icon images. Image picker in page builder now has an upload button and alt text warning badges. Clearing unused image references shows an orphan info flash. Co-Authored-By: Claude Opus 4.6 --- PROGRESS.md | 7 +- assets/css/admin/components.css | 33 ++++ lib/berrypod/legal_pages.ex | 42 +++++ .../components/block_editor_components.ex | 18 ++ lib/berrypod_web/live/admin/pages/editor.ex | 178 +++++++++++++++++- lib/berrypod_web/live/admin/theme/index.ex | 30 +++ .../live/admin/theme/index.html.heex | 46 +++++ test/berrypod/legal_pages_test.exs | 92 +++++++++ test/berrypod_web/live/admin/pages_test.exs | 60 ++++++ 9 files changed, 495 insertions(+), 11 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 31d9bab..992db8f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -148,7 +148,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m | ~~99~~ | ~~Stage 3: admin CRUD — create/edit/delete pages, page settings, admin index~~ | 98 | 2.5h | done | | ~~100~~ | ~~Stage 4: navigation management — data-driven nav, settings storage, admin editor~~ | 99 | 3h | done | | ~~101~~ | ~~Stage 5: SEO + redirects — sitemap, auto-redirect on slug change, draft/published~~ | 100 | 1h | done | -| 102 | Stage 6: polish — page templates, new block types, bulk ops | 101 | 3-4h | deferred | +| ~~102~~ | ~~Stage 6: polish — page templates, new block types, bulk ops~~ | 101 | 3-4h | done | | | **Platform site** | | | | | 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned | | 74 | Separation of concerns: platform site vs AGPL open source core | 73 | TBD | planned | @@ -487,9 +487,9 @@ Admin media library at `/admin/media` with image grid, type/search/orphan filter See: [docs/plans/media-library.md](docs/plans/media-library.md) for full plan ### Page Editor -**Status:** Complete — all 9 stages done, 1485 tests +**Status:** Complete — all 9 stages + custom pages done, 1485 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). Custom CMS pages with 4 utility block types (spacer, divider, button/CTA, video embed), page templates (blank/content/landing) for new page creation, duplicate page action, and mobile sidebar fix for the block picker. **Stages:** 1. ~~Foundation — data model, cache, block registry~~ ✅ (`35f96e4`) @@ -502,6 +502,7 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON 7b. ~~SettingsField struct + repeater field type for info_card items~~ ✅ (`6fbd654`) 8. ~~Live page editor sidebar — collapsible sidebar on shop pages, backdrop dismiss, portable content block~~ ✅ (`a039c8d`) 9. ~~Undo/redo + polish — history stacks, keyboard shortcuts, settings animations~~ ✅ +10. ~~Custom pages stage 6 — 4 utility block types (spacer, divider, button/CTA, video embed), page templates (blank/content/landing), duplicate page, mobile block picker fix~~ ✅ **Key files created:** - `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data) diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 277ec0b..5feb7f9 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1869,6 +1869,7 @@ } .image-picker-item { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -1927,6 +1928,38 @@ font-size: 0.8125rem; } +.image-picker-upload { + margin-bottom: 0.75rem; +} + +.image-picker-upload-label { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem; + border: 1px dashed var(--t-border-default); + border-radius: 0.375rem; + font-size: 0.8125rem; + cursor: pointer; + color: color-mix(in oklch, var(--t-text-primary) 60%, transparent); + transition: border-color 100ms, color 100ms; + + @media (hover: hover) { + &:hover { + border-color: var(--t-text-primary); + color: var(--t-text-primary); + } + } +} + +.image-picker-item-no-alt { + color: var(--t-warning, #f59e0b); + position: absolute; + top: 0.25rem; + right: 0.25rem; +} + /* ═══════════════════════════════════════════════════════════════════ Media library ═══════════════════════════════════════════════════════════════════ */ diff --git a/lib/berrypod/legal_pages.ex b/lib/berrypod/legal_pages.ex index 1638388..1da046e 100644 --- a/lib/berrypod/legal_pages.ex +++ b/lib/berrypod/legal_pages.ex @@ -442,4 +442,46 @@ defmodule Berrypod.LegalPages do defp capitalise(<>), do: String.upcase(<>) <> rest defp capitalise(""), do: "" + + # ============================================================================= + # Page editor integration + # ============================================================================= + + @legal_slugs ~w(privacy delivery terms) + + @doc """ + Returns true if the given slug is a legal page that supports regeneration. + """ + def legal_slug?(slug), do: slug in @legal_slugs + + @doc """ + Generates plain text content for a legal page, suitable for the + `content_body` block's textarea field. + """ + def regenerate_legal_content("privacy"), do: rich_text_to_plain(privacy_content()) + def regenerate_legal_content("delivery"), do: rich_text_to_plain(delivery_content()) + def regenerate_legal_content("terms"), do: rich_text_to_plain(terms_content()) + + @doc """ + Converts rich text blocks (as returned by `*_content/0`) to a plain text + string with markdown-style headings. Double newlines separate sections. + """ + def rich_text_to_plain(blocks) when is_list(blocks) do + blocks + |> Enum.map(&block_to_text/1) + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n\n") + end + + defp block_to_text(%{type: :lead, text: text}), do: text + defp block_to_text(%{type: :heading, text: text}), do: "## #{text}" + defp block_to_text(%{type: :paragraph, text: text}), do: text + + defp block_to_text(%{type: :list, items: items}) do + Enum.map_join(items, "\n", &"• #{&1}") + end + + defp block_to_text(%{type: :updated_at, date: date}), do: "Last updated: #{date}" + defp block_to_text(%{type: :closing, text: text}), do: text + defp block_to_text(_), do: "" end diff --git a/lib/berrypod_web/components/block_editor_components.ex b/lib/berrypod_web/components/block_editor_components.ex index c634eb6..20c5446 100644 --- a/lib/berrypod_web/components/block_editor_components.ex +++ b/lib/berrypod_web/components/block_editor_components.ex @@ -471,6 +471,7 @@ defmodule BerrypodWeb.BlockEditorComponents do attr :images, :list, required: true attr :search, :string, required: true attr :event_prefix, :string, default: "" + attr :upload, :any, default: nil def image_picker(assigns) do search = String.downcase(assigns.search) @@ -511,6 +512,16 @@ defmodule BerrypodWeb.BlockEditorComponents do autofocus /> +
+
+ +
+
+

diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index 438b441..45e2290 100644 --- a/lib/berrypod_web/live/admin/pages/editor.ex +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -1,7 +1,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do use BerrypodWeb, :live_view - alias Berrypod.{Media, Pages} + alias Berrypod.{LegalPages, Media, Pages} alias Berrypod.Pages.{BlockEditor, BlockTypes, Page} alias Berrypod.Products.ProductImage alias Berrypod.Theme.{Fonts, PreviewData} @@ -41,7 +41,98 @@ defmodule BerrypodWeb.Admin.Pages.Editor do |> assign(:image_picker_field_key, nil) |> assign(:image_picker_images, []) |> assign(:image_picker_search, "") - |> assign(:is_custom_page, !Page.system_slug?(slug))} + |> assign(:is_custom_page, !Page.system_slug?(slug)) + |> assign(:is_legal_page, LegalPages.legal_slug?(slug)) + |> allow_upload(:image_picker_upload, + accept: ~w(.png .jpg .jpeg .webp .svg), + max_entries: 1, + max_file_size: 5_000_000, + auto_upload: true, + progress: &handle_image_picker_upload/3 + ) + |> assign_content_source(slug, page.blocks)} + end + + defp handle_image_picker_upload(:image_picker_upload, entry, socket) do + if entry.done? do + consume_uploaded_entries(socket, :image_picker_upload, fn %{path: path}, entry -> + case Media.upload_from_entry(path, entry, "media") do + {:ok, image} -> {:ok, image} + {:error, _} = error -> error + end + end) + |> case do + [{:ok, image}] -> + 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 uploaded and selected") + |> assign(:image_picker_block_id, nil) + |> assign(:image_picker_field_key, nil)} + + :noop -> + {:noreply, socket} + end + + _ -> + {:noreply, put_flash(socket, :error, "Upload failed")} + end + else + {:noreply, socket} + end + end + + # Determines whether legal page content is auto-generated or customised. + # For non-legal pages, content_source is nil (not shown). + # On mount, auto-populates empty legal pages with generated content. + defp assign_content_source(socket, slug, blocks) do + if LegalPages.legal_slug?(slug) do + current_content = get_content_body_text(blocks) + fresh_content = LegalPages.regenerate_legal_content(slug) + + cond do + # Empty content — auto-populate on mount + current_content == "" -> + blocks = populate_content_body(blocks, fresh_content) + + socket + |> assign(:blocks, blocks) + |> assign(:content_source, :auto) + + # Content matches generated — it's auto + current_content == fresh_content -> + assign(socket, :content_source, :auto) + + # Content differs — it's been customised + true -> + assign(socket, :content_source, :custom) + end + else + assign(socket, :content_source, nil) + end + end + + defp populate_content_body(blocks, content) do + Enum.map(blocks, fn + %{"type" => "content_body", "settings" => settings} = block -> + %{block | "settings" => Map.put(settings || %{}, "content", content)} + + block -> + block + end) + end + + defp get_content_body_text(blocks) do + case Enum.find(blocks, &(&1["type"] == "content_body")) do + %{"settings" => %{"content" => content}} when is_binary(content) -> content + _ -> "" + end end # ── Block manipulation events ──────────────────────────────────── @@ -101,7 +192,17 @@ defmodule BerrypodWeb.Admin.Pages.Editor do case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do {:ok, new_blocks} -> - {:noreply, apply_mutation(socket, new_blocks, "Settings updated")} + socket = apply_mutation(socket, new_blocks, "Settings updated") + + # Update content source badge if a legal page content block was edited + socket = + if socket.assigns.is_legal_page do + assign_content_source(socket, socket.assigns.slug, new_blocks) + else + socket + end + + {:noreply, socket} :noop -> {:noreply, socket} @@ -202,9 +303,30 @@ defmodule BerrypodWeb.Admin.Pages.Editor do %{"block-id" => block_id, "field" => field_key}, socket ) do + # Grab the old image_id before clearing + old_image_id = + case Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) do + %{"settings" => settings} -> settings[field_key] + _ -> nil + end + case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do {:ok, new_blocks} -> - {:noreply, apply_mutation(socket, new_blocks, "Image cleared")} + socket = apply_mutation(socket, new_blocks, "Image cleared") + + socket = + if is_binary(old_image_id) && old_image_id != "" && + Media.find_usages(old_image_id) == [] do + put_flash( + socket, + :info, + "Image removed — it's no longer used anywhere and can be deleted from the media library" + ) + else + socket + end + + {:noreply, socket} :noop -> {:noreply, socket} @@ -267,6 +389,29 @@ defmodule BerrypodWeb.Admin.Pages.Editor do end end + def handle_event("regenerate_legal", _params, socket) do + slug = socket.assigns.slug + content = LegalPages.regenerate_legal_content(slug) + + case Enum.find(socket.assigns.blocks, &(&1["type"] == "content_body")) do + %{"id" => block_id} -> + case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{"content" => content}) do + {:ok, new_blocks} -> + {:noreply, + socket + |> apply_mutation(new_blocks, "Legal content regenerated") + |> assign(:content_source, :auto) + |> put_flash(:info, "Content regenerated from current settings")} + + :noop -> + {:noreply, socket} + end + + nil -> + {:noreply, put_flash(socket, :error, "No content block found on this page")} + end + end + def handle_event("reset_defaults", _params, socket) do slug = socket.assigns.slug :ok = Pages.reset_page(slug) @@ -365,6 +510,14 @@ defmodule BerrypodWeb.Admin.Pages.Editor do > <.icon name="hero-arrow-uturn-right" class="size-4" /> +

+
+ +

+ Missing alt text — add a description for accessibility +

+
<% end %> @@ -449,6 +472,29 @@ × +
+ +

+ Missing alt text — add a description for accessibility +

+
diff --git a/test/berrypod/legal_pages_test.exs b/test/berrypod/legal_pages_test.exs index 508e93a..f48586e 100644 --- a/test/berrypod/legal_pages_test.exs +++ b/test/berrypod/legal_pages_test.exs @@ -252,4 +252,96 @@ defmodule Berrypod.LegalPagesTest do assert List.last(blocks).type == :closing end end + + # --------------------------------------------------------------------------- + # regenerate_legal_content/1 + # --------------------------------------------------------------------------- + + describe "regenerate_legal_content/1" do + test "returns a non-empty string for privacy" do + result = LegalPages.regenerate_legal_content("privacy") + assert is_binary(result) + assert String.length(result) > 100 + end + + test "returns a non-empty string for delivery" do + result = LegalPages.regenerate_legal_content("delivery") + assert is_binary(result) + assert String.length(result) > 100 + end + + test "returns a non-empty string for terms" do + result = LegalPages.regenerate_legal_content("terms") + assert is_binary(result) + assert String.length(result) > 100 + end + end + + # --------------------------------------------------------------------------- + # rich_text_to_plain/1 + # --------------------------------------------------------------------------- + + describe "rich_text_to_plain/1" do + test "converts headings to markdown-style" do + blocks = [%{type: :heading, text: "Hello"}] + assert LegalPages.rich_text_to_plain(blocks) == "## Hello" + end + + test "converts paragraphs as-is" do + blocks = [%{type: :paragraph, text: "Some text here."}] + assert LegalPages.rich_text_to_plain(blocks) == "Some text here." + end + + test "converts lists to bullet points" do + blocks = [%{type: :list, items: ["One", "Two", "Three"]}] + assert LegalPages.rich_text_to_plain(blocks) == "• One\n• Two\n• Three" + end + + test "converts lead blocks" do + blocks = [%{type: :lead, text: "Important intro"}] + assert LegalPages.rich_text_to_plain(blocks) == "Important intro" + end + + test "converts updated_at blocks" do + blocks = [%{type: :updated_at, date: "28 February 2026"}] + assert LegalPages.rich_text_to_plain(blocks) == "Last updated: 28 February 2026" + end + + test "joins multiple blocks with double newlines" do + blocks = [ + %{type: :heading, text: "Title"}, + %{type: :paragraph, text: "Body text."} + ] + + assert LegalPages.rich_text_to_plain(blocks) == "## Title\n\nBody text." + end + + test "skips unknown block types" do + blocks = [ + %{type: :heading, text: "Title"}, + %{type: :unknown_thing, data: "whatever"}, + %{type: :paragraph, text: "Body."} + ] + + assert LegalPages.rich_text_to_plain(blocks) == "## Title\n\nBody." + end + end + + # --------------------------------------------------------------------------- + # legal_slug?/1 + # --------------------------------------------------------------------------- + + describe "legal_slug?/1" do + test "returns true for legal page slugs" do + assert LegalPages.legal_slug?("privacy") + assert LegalPages.legal_slug?("delivery") + assert LegalPages.legal_slug?("terms") + end + + test "returns false for non-legal slugs" do + refute LegalPages.legal_slug?("home") + refute LegalPages.legal_slug?("about") + refute LegalPages.legal_slug?("contact") + end + end end diff --git a/test/berrypod_web/live/admin/pages_test.exs b/test/berrypod_web/live/admin/pages_test.exs index 467135f..6bf6170 100644 --- a/test/berrypod_web/live/admin/pages_test.exs +++ b/test/berrypod_web/live/admin/pages_test.exs @@ -1004,6 +1004,66 @@ defmodule BerrypodWeb.Admin.PagesTest do end end + describe "legal page editor" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "shows regenerate button for privacy page", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/privacy") + + assert has_element?(view, "button[phx-click='regenerate_legal']", "Regenerate") + end + + test "shows regenerate button for delivery page", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/delivery") + + assert has_element?(view, "button[phx-click='regenerate_legal']", "Regenerate") + end + + test "does not show regenerate button for home page", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + refute has_element?(view, "button[phx-click='regenerate_legal']") + end + + test "shows auto-generated badge for legal pages on mount", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/privacy") + + assert has_element?(view, ".admin-badge-info", "Auto-generated from settings") + end + + test "auto-populates content_body on mount for legal pages", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/privacy") + + # The content_body block should be expanded to see the content + page = Pages.get_page("privacy") + content_body = Enum.find(page.blocks, &(&1["type"] == "content_body")) + render_click(view, "toggle_expand", %{"id" => content_body["id"]}) + + html = render(view) + # Should contain generated legal content + assert html =~ "What we collect" + end + + test "shows customised badge after manual edit", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/privacy") + + page = Pages.get_page("privacy") + content_body = Enum.find(page.blocks, &(&1["type"] == "content_body")) + + render_click(view, "toggle_expand", %{"id" => content_body["id"]}) + + render_change(view, "update_block_settings", %{ + "block_id" => content_body["id"], + "block_settings" => %{"content" => "Custom privacy content here"} + }) + + assert has_element?(view, ".admin-badge", "Customised") + refute has_element?(view, ".admin-badge-info", "Auto-generated from settings") + end + end + defp count_repeater_items(html) do Regex.scan(~r/class="repeater-item"/, html) |> length() end