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:
@@ -442,4 +442,46 @@ defmodule Berrypod.LegalPages do
|
||||
|
||||
defp capitalise(<<first::utf8, rest::binary>>), do: String.upcase(<<first::utf8>>) <> 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
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
<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">
|
||||
<button
|
||||
:for={image <- @filtered_images}
|
||||
@@ -532,6 +543,13 @@ defmodule BerrypodWeb.BlockEditorComponents do
|
||||
/>
|
||||
<% end %>
|
||||
<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>
|
||||
|
||||
<p :if={@filtered_images == []} class="image-picker-empty">
|
||||
|
||||
@@ -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" />
|
||||
</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
|
||||
:if={!@is_custom_page}
|
||||
phx-click="reset_defaults"
|
||||
@@ -388,10 +541,18 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
{if @live_region_message, do: @live_region_message}
|
||||
</div>
|
||||
|
||||
<%!-- Unsaved changes indicator --%>
|
||||
<p :if={@dirty} class="admin-badge admin-badge-warning mt-4">
|
||||
Unsaved changes
|
||||
</p>
|
||||
<%!-- Status badges --%>
|
||||
<div class="mt-4 flex gap-2 flex-wrap">
|
||||
<p :if={@dirty} class="admin-badge admin-badge-warning">
|
||||
Unsaved changes
|
||||
</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">
|
||||
<%!-- Editor pane --%>
|
||||
@@ -452,6 +613,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
:if={@image_picker_block_id}
|
||||
images={@image_picker_images}
|
||||
search={@image_picker_search}
|
||||
upload={@uploads.image_picker_upload}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@@ -285,6 +285,36 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
||||
{:noreply, socket}
|
||||
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
|
||||
def handle_event("remove_logo", _params, socket) do
|
||||
if logo = socket.assigns.logo_image do
|
||||
|
||||
@@ -172,6 +172,29 @@
|
||||
×
|
||||
</button>
|
||||
</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 %>
|
||||
</div>
|
||||
|
||||
@@ -449,6 +472,29 @@
|
||||
×
|
||||
</button>
|
||||
</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 -->
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
|
||||
Reference in New Issue
Block a user