add admin CRUD for custom CMS pages
All checks were successful
deploy / deploy (push) Successful in 1m21s
All checks were successful
deploy / deploy (push) Successful in 1m21s
New settings form for creating and editing custom page metadata (title, slug, meta description, published, nav settings). Pages index shows custom pages section with draft badges and delete. Editor shows settings button for custom pages, hides reset to defaults. 20 new tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
158
lib/berrypod_web/live/admin/pages/custom_form.ex
Normal file
158
lib/berrypod_web/live/admin/pages/custom_form.ex
Normal file
@@ -0,0 +1,158 @@
|
||||
defmodule BerrypodWeb.Admin.Pages.CustomForm do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Pages
|
||||
alias Berrypod.Pages.Page
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
{:ok, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
changeset = Page.custom_changeset(%Page{}, %{})
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "New page")
|
||||
|> assign(:page_struct, %Page{})
|
||||
|> assign(:slug_touched, false)
|
||||
|> assign(:form, to_form(changeset))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"slug" => slug}) do
|
||||
case Pages.get_page_struct(slug) do
|
||||
%Page{type: "custom"} = page ->
|
||||
changeset = Page.custom_changeset(page, %{})
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "#{page.title} settings")
|
||||
|> assign(:page_struct, page)
|
||||
|> assign(:slug_touched, true)
|
||||
|> assign(:form, to_form(changeset))
|
||||
|
||||
_ ->
|
||||
socket
|
||||
|> put_flash(:error, "Page not found")
|
||||
|> push_navigate(to: ~p"/admin/pages")
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"page" => params}, socket) do
|
||||
params = maybe_slugify(params, socket)
|
||||
|
||||
form =
|
||||
socket.assigns.page_struct
|
||||
|> Page.custom_changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
slug_touched =
|
||||
socket.assigns.slug_touched || (params["slug"] && params["slug"] != "")
|
||||
|
||||
{:noreply, assign(socket, form: form, slug_touched: slug_touched)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"page" => params}, socket) do
|
||||
save_page(socket, socket.assigns.live_action, params)
|
||||
end
|
||||
|
||||
defp save_page(socket, :new, params) do
|
||||
case Pages.create_custom_page(params) do
|
||||
{:ok, page} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Page created")
|
||||
|> push_navigate(to: ~p"/admin/pages/#{page.slug}")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_page(socket, :edit, params) do
|
||||
case Pages.update_custom_page(socket.assigns.page_struct, params) do
|
||||
{:ok, page} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Page settings saved")
|
||||
|> push_navigate(to: ~p"/admin/pages/#{page.slug}")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
# Auto-generate slug from title when creating and slug hasn't been manually edited
|
||||
defp maybe_slugify(params, %{assigns: %{live_action: :new, slug_touched: false}}) do
|
||||
case params["title"] do
|
||||
title when is_binary(title) and title != "" ->
|
||||
slug =
|
||||
title
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[^a-z0-9\s-]/, "")
|
||||
|> String.trim()
|
||||
|> String.replace(~r/\s+/, "-")
|
||||
|> String.replace(~r/-+/, "-")
|
||||
|> String.trim("-")
|
||||
|
||||
Map.put(params, "slug", slug)
|
||||
|
||||
_ ->
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_slugify(params, _socket), do: params
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
navigate={~p"/admin/pages"}
|
||||
class="text-sm font-normal text-base-content/60 hover:underline"
|
||||
>
|
||||
← Pages
|
||||
</.link>
|
||||
|
||||
<.header>
|
||||
{if @live_action == :new, do: "New page", else: "Page settings"}
|
||||
</.header>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id="custom-page-form"
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
class="mt-6 space-y-6"
|
||||
style="max-width: 32rem;"
|
||||
>
|
||||
<.input field={@form[:title]} label="Title" />
|
||||
<.input field={@form[:slug]} label="URL slug" />
|
||||
<.input
|
||||
field={@form[:meta_description]}
|
||||
type="textarea"
|
||||
label="Meta description"
|
||||
phx-no-feedback
|
||||
/>
|
||||
<.input field={@form[:published]} type="checkbox" label="Published" />
|
||||
<.input field={@form[:show_in_nav]} type="checkbox" label="Show in navigation" />
|
||||
|
||||
<div :if={@form[:show_in_nav].value == true} class="space-y-4 pl-6">
|
||||
<.input field={@form[:nav_label]} label="Nav label" />
|
||||
<.input field={@form[:nav_position]} type="number" label="Nav position" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<.button type="submit" phx-disable-with="Saving...">
|
||||
{if @live_action == :new, do: "Create page", else: "Save settings"}
|
||||
</.button>
|
||||
<.link navigate={~p"/admin/pages"} class="admin-btn admin-btn-ghost">
|
||||
Cancel
|
||||
</.link>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.{Media, Pages}
|
||||
alias Berrypod.Pages.{BlockEditor, BlockTypes}
|
||||
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
|
||||
alias Berrypod.Products.ProductImage
|
||||
alias Berrypod.Theme.{Fonts, PreviewData}
|
||||
|
||||
@@ -38,7 +38,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
|> assign(:image_picker_block_id, nil)
|
||||
|> assign(:image_picker_field_key, nil)
|
||||
|> assign(:image_picker_images, [])
|
||||
|> assign(:image_picker_search, "")}
|
||||
|> assign(:image_picker_search, "")
|
||||
|> assign(:is_custom_page, !Page.system_slug?(slug))}
|
||||
end
|
||||
|
||||
# ── Block manipulation events ────────────────────────────────────
|
||||
@@ -310,7 +311,15 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
/>
|
||||
{if @show_preview, do: "Edit", else: "Preview"}
|
||||
</button>
|
||||
<.link
|
||||
:if={@is_custom_page}
|
||||
navigate={~p"/admin/pages/#{@slug}/settings"}
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
>
|
||||
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
|
||||
</.link>
|
||||
<button
|
||||
:if={!@is_custom_page}
|
||||
phx-click="reset_defaults"
|
||||
data-confirm="Reset this page to its default layout? Your changes will be lost."
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
|
||||
@@ -14,20 +14,43 @@ defmodule BerrypodWeb.Admin.Pages.Index do
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
pages = Pages.list_pages() |> Map.new(&{&1.slug, &1})
|
||||
custom_pages = Pages.list_custom_pages()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Pages")
|
||||
|> assign(:pages, pages)
|
||||
|> assign(:custom_pages, custom_pages)
|
||||
|> assign(:page_groups, @page_groups)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete_custom_page", %{"slug" => slug}, socket) do
|
||||
case Pages.get_page_struct(slug) do
|
||||
%{type: "custom"} = page ->
|
||||
{:ok, _} = Pages.delete_custom_page(page)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:custom_pages, Pages.list_custom_pages())
|
||||
|> put_flash(:info, "Page deleted")}
|
||||
|
||||
_ ->
|
||||
{:noreply, put_flash(socket, :error, "Page not found")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Pages
|
||||
<:subtitle>Customise the layout and content of every page on your shop.</:subtitle>
|
||||
<:actions>
|
||||
<.link navigate={~p"/admin/pages/new"} class="admin-btn admin-btn-sm admin-btn-primary">
|
||||
<.icon name="hero-plus" class="size-4" /> New page
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="page-list">
|
||||
@@ -54,6 +77,42 @@ defmodule BerrypodWeb.Admin.Pages.Index do
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={@custom_pages != []} class="page-group">
|
||||
<h3 class="page-group-title">Custom pages</h3>
|
||||
<div class="page-group-cards">
|
||||
<div :for={page <- @custom_pages} class="page-card page-card-custom">
|
||||
<.link navigate={~p"/admin/pages/#{page.slug}"} class="page-card-link">
|
||||
<span class="page-card-icon">
|
||||
<.icon name="hero-document" class="size-5" />
|
||||
</span>
|
||||
<span class="page-card-info">
|
||||
<span class="page-card-title">
|
||||
{page.title}
|
||||
<span :if={!page.published} class="admin-badge admin-badge-warning ml-2">
|
||||
Draft
|
||||
</span>
|
||||
</span>
|
||||
<span class="page-card-meta">
|
||||
/{page.slug} · {length(page.blocks)} {if length(page.blocks) == 1,
|
||||
do: "block",
|
||||
else: "blocks"}
|
||||
</span>
|
||||
</span>
|
||||
<.icon name="hero-chevron-right-mini" class="size-4 page-card-arrow" />
|
||||
</.link>
|
||||
<button
|
||||
phx-click="delete_custom_page"
|
||||
phx-value-slug={page.slug}
|
||||
data-confirm={"Delete \"#{page.title}\"? This cannot be undone."}
|
||||
class="page-card-delete"
|
||||
aria-label={"Delete #{page.title}"}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@@ -155,6 +155,8 @@ defmodule BerrypodWeb.Router do
|
||||
live "/settings", Admin.Settings, :index
|
||||
live "/settings/email", Admin.EmailSettings, :index
|
||||
live "/pages", Admin.Pages.Index, :index
|
||||
live "/pages/new", Admin.Pages.CustomForm, :new
|
||||
live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit
|
||||
live "/pages/:slug", Admin.Pages.Editor, :edit
|
||||
live "/media", Admin.Media, :index
|
||||
live "/redirects", Admin.Redirects, :index
|
||||
|
||||
Reference in New Issue
Block a user