add legal page editor integration and media library polish
Some checks failed
deploy / deploy (push) Has been cancelled
Some checks failed
deploy / deploy (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
parent
3336b3aa26
commit
93ff66debc
@ -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 |
|
| ~~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 |
|
| ~~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 |
|
| ~~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** | | | |
|
| | **Platform site** | | | |
|
||||||
| 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned |
|
| 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned |
|
||||||
| 74 | Separation of concerns: platform site vs AGPL open source core | 73 | 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
|
See: [docs/plans/media-library.md](docs/plans/media-library.md) for full plan
|
||||||
|
|
||||||
### Page Editor
|
### 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:**
|
**Stages:**
|
||||||
1. ~~Foundation — data model, cache, block registry~~ ✅ (`35f96e4`)
|
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`)
|
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`)
|
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~~ ✅
|
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:**
|
**Key files created:**
|
||||||
- `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data)
|
- `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data)
|
||||||
|
|||||||
@ -1869,6 +1869,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.image-picker-item {
|
.image-picker-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1927,6 +1928,38 @@
|
|||||||
font-size: 0.8125rem;
|
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
|
Media library
|
||||||
═══════════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
@ -442,4 +442,46 @@ defmodule Berrypod.LegalPages do
|
|||||||
|
|
||||||
defp capitalise(<<first::utf8, rest::binary>>), do: String.upcase(<<first::utf8>>) <> rest
|
defp capitalise(<<first::utf8, rest::binary>>), do: String.upcase(<<first::utf8>>) <> rest
|
||||||
defp capitalise(""), do: ""
|
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
|
end
|
||||||
|
|||||||
@ -471,6 +471,7 @@ defmodule BerrypodWeb.BlockEditorComponents do
|
|||||||
attr :images, :list, required: true
|
attr :images, :list, required: true
|
||||||
attr :search, :string, required: true
|
attr :search, :string, required: true
|
||||||
attr :event_prefix, :string, default: ""
|
attr :event_prefix, :string, default: ""
|
||||||
|
attr :upload, :any, default: nil
|
||||||
|
|
||||||
def image_picker(assigns) do
|
def image_picker(assigns) do
|
||||||
search = String.downcase(assigns.search)
|
search = String.downcase(assigns.search)
|
||||||
@ -511,6 +512,16 @@ defmodule BerrypodWeb.BlockEditorComponents do
|
|||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div :if={@upload} class="image-picker-upload">
|
||||||
|
<form phx-change="noop" phx-submit="noop">
|
||||||
|
<label class="image-picker-upload-label">
|
||||||
|
<.icon name="hero-arrow-up-tray" class="size-4" />
|
||||||
|
<span>Upload new image</span>
|
||||||
|
<.live_file_input upload={@upload} class="hidden" />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="image-picker-grid">
|
<div class="image-picker-grid">
|
||||||
<button
|
<button
|
||||||
:for={image <- @filtered_images}
|
:for={image <- @filtered_images}
|
||||||
@ -532,6 +543,13 @@ defmodule BerrypodWeb.BlockEditorComponents do
|
|||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
<span class="image-picker-item-name">{image.filename}</span>
|
<span class="image-picker-item-name">{image.filename}</span>
|
||||||
|
<span
|
||||||
|
:if={!image.alt || image.alt == ""}
|
||||||
|
class="image-picker-item-no-alt"
|
||||||
|
title="Missing alt text"
|
||||||
|
>
|
||||||
|
<.icon name="hero-exclamation-triangle-mini" class="size-3" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p :if={@filtered_images == []} class="image-picker-empty">
|
<p :if={@filtered_images == []} class="image-picker-empty">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
defmodule BerrypodWeb.Admin.Pages.Editor do
|
defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||||
use BerrypodWeb, :live_view
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
alias Berrypod.{Media, Pages}
|
alias Berrypod.{LegalPages, Media, Pages}
|
||||||
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
|
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
|
||||||
alias Berrypod.Products.ProductImage
|
alias Berrypod.Products.ProductImage
|
||||||
alias Berrypod.Theme.{Fonts, PreviewData}
|
alias Berrypod.Theme.{Fonts, PreviewData}
|
||||||
@ -41,7 +41,98 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
|> assign(:image_picker_field_key, nil)
|
|> assign(:image_picker_field_key, nil)
|
||||||
|> assign(:image_picker_images, [])
|
|> assign(:image_picker_images, [])
|
||||||
|> assign(:image_picker_search, "")
|
|> 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
|
end
|
||||||
|
|
||||||
# ── Block manipulation events ────────────────────────────────────
|
# ── 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
|
case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do
|
||||||
{:ok, new_blocks} ->
|
{: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 ->
|
:noop ->
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@ -202,9 +303,30 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
%{"block-id" => block_id, "field" => field_key},
|
%{"block-id" => block_id, "field" => field_key},
|
||||||
socket
|
socket
|
||||||
) do
|
) 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
|
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do
|
||||||
{:ok, new_blocks} ->
|
{: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 ->
|
:noop ->
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@ -267,6 +389,29 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
end
|
end
|
||||||
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
|
def handle_event("reset_defaults", _params, socket) do
|
||||||
slug = socket.assigns.slug
|
slug = socket.assigns.slug
|
||||||
:ok = Pages.reset_page(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" />
|
<.icon name="hero-arrow-uturn-right" class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:if={@is_legal_page}
|
||||||
|
phx-click="regenerate_legal"
|
||||||
|
data-confirm="Regenerate this page's content from your current shop settings? Any manual edits will be replaced."
|
||||||
|
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||||
|
>
|
||||||
|
<.icon name="hero-arrow-path" class="size-4" /> Regenerate
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
:if={!@is_custom_page}
|
:if={!@is_custom_page}
|
||||||
phx-click="reset_defaults"
|
phx-click="reset_defaults"
|
||||||
@ -388,10 +541,18 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
{if @live_region_message, do: @live_region_message}
|
{if @live_region_message, do: @live_region_message}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Unsaved changes indicator --%>
|
<%!-- Status badges --%>
|
||||||
<p :if={@dirty} class="admin-badge admin-badge-warning mt-4">
|
<div class="mt-4 flex gap-2 flex-wrap">
|
||||||
|
<p :if={@dirty} class="admin-badge admin-badge-warning">
|
||||||
Unsaved changes
|
Unsaved changes
|
||||||
</p>
|
</p>
|
||||||
|
<p :if={@content_source == :auto} class="admin-badge admin-badge-info">
|
||||||
|
Auto-generated from settings
|
||||||
|
</p>
|
||||||
|
<p :if={@content_source == :custom} class="admin-badge">
|
||||||
|
Customised
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page-editor-container">
|
<div class="page-editor-container">
|
||||||
<%!-- Editor pane --%>
|
<%!-- Editor pane --%>
|
||||||
@ -452,6 +613,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
:if={@image_picker_block_id}
|
:if={@image_picker_block_id}
|
||||||
images={@image_picker_images}
|
images={@image_picker_images}
|
||||||
search={@image_picker_search}
|
search={@image_picker_search}
|
||||||
|
upload={@uploads.image_picker_upload}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -285,6 +285,36 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("update_image_alt", %{"image-id" => image_id, "alt" => alt}, socket) do
|
||||||
|
case Media.get_image(image_id) do
|
||||||
|
nil ->
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
image ->
|
||||||
|
{:ok, updated} = Media.update_image_metadata(image, %{alt: alt})
|
||||||
|
|
||||||
|
# Refresh the relevant assign so the template sees the new alt text
|
||||||
|
socket =
|
||||||
|
cond do
|
||||||
|
socket.assigns.logo_image && socket.assigns.logo_image.id == image_id ->
|
||||||
|
assign(socket, :logo_image, updated)
|
||||||
|
|
||||||
|
socket.assigns.header_image && socket.assigns.header_image.id == image_id ->
|
||||||
|
assign(socket, :header_image, updated)
|
||||||
|
|
||||||
|
socket.assigns[:icon_image] && socket.assigns.icon_image &&
|
||||||
|
socket.assigns.icon_image.id == image_id ->
|
||||||
|
assign(socket, :icon_image, updated)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("remove_logo", _params, socket) do
|
def handle_event("remove_logo", _params, socket) do
|
||||||
if logo = socket.assigns.logo_image do
|
if logo = socket.assigns.logo_image do
|
||||||
|
|||||||
@ -172,6 +172,29 @@
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<form
|
||||||
|
phx-change="update_image_alt"
|
||||||
|
phx-value-image-id={@logo_image.id}
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-base-content/60 shrink-0">Alt text</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="alt"
|
||||||
|
value={@logo_image.alt || ""}
|
||||||
|
placeholder="Describe this image"
|
||||||
|
class="admin-input admin-input-sm flex-1"
|
||||||
|
phx-debounce="blur"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
:if={!@logo_image.alt || @logo_image.alt == ""}
|
||||||
|
class="text-xs text-warning mt-1"
|
||||||
|
>
|
||||||
|
Missing alt text — add a description for accessibility
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -449,6 +472,29 @@
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<form
|
||||||
|
phx-change="update_image_alt"
|
||||||
|
phx-value-image-id={@header_image.id}
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-base-content/60 shrink-0">Alt text</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="alt"
|
||||||
|
value={@header_image.alt || ""}
|
||||||
|
placeholder="Describe this image"
|
||||||
|
class="admin-input admin-input-sm flex-1"
|
||||||
|
phx-debounce="blur"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
:if={!@header_image.alt || @header_image.alt == ""}
|
||||||
|
class="text-xs text-warning mt-1"
|
||||||
|
>
|
||||||
|
Missing alt text — add a description for accessibility
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- Header Image Controls -->
|
<!-- Header Image Controls -->
|
||||||
<div class="mt-3 flex flex-col gap-3">
|
<div class="mt-3 flex flex-col gap-3">
|
||||||
|
|||||||
@ -252,4 +252,96 @@ defmodule Berrypod.LegalPagesTest do
|
|||||||
assert List.last(blocks).type == :closing
|
assert List.last(blocks).type == :closing
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@ -1004,6 +1004,66 @@ defmodule BerrypodWeb.Admin.PagesTest do
|
|||||||
end
|
end
|
||||||
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
|
defp count_repeater_items(html) do
|
||||||
Regex.scan(~r/class="repeater-item"/, html) |> length()
|
Regex.scan(~r/class="repeater-item"/, html) |> length()
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user