add SEO enhancements: OG images, meta robots, FAQ block, image sitemap
All checks were successful
deploy / deploy (push) Successful in 4m59s
All checks were successful
deploy / deploy (push) Successful in 4m59s
- Per-page SEO controls: meta robots directives, focus keyword, OG image - Site-wide default OG image in admin settings - FAQ block type with FAQPage JSON-LD schema - Enhanced Organization JSON-LD with business info, contact, address - Image sitemap with product images - SEO preview panel with Google/social card mockups - SEO checklist with real-time scoring - Business info section in site editor - GSC integration scaffolding (OAuth, client, cache) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
alias Berrypod.Theme.{Fonts, PreviewData}
|
||||
|
||||
import BerrypodWeb.BlockEditorComponents
|
||||
import BerrypodWeb.Components.SeoChecklist
|
||||
import BerrypodWeb.Components.SeoPreview
|
||||
|
||||
@impl true
|
||||
def mount(%{"slug" => slug}, _session, socket) do
|
||||
@@ -503,19 +505,77 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("show_og_picker", _params, socket) do
|
||||
images = Media.list_images()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:settings_og_picker_open, true)
|
||||
|> assign(:settings_og_picker_images, images)}
|
||||
end
|
||||
|
||||
def handle_event("hide_og_picker", _params, socket) do
|
||||
{:noreply, assign(socket, :settings_og_picker_open, false)}
|
||||
end
|
||||
|
||||
def handle_event("pick_og_image", %{"image-id" => image_id}, socket) do
|
||||
image = Media.get_image(image_id)
|
||||
|
||||
# Update the form with the new og_image_id
|
||||
current_params = socket.assigns.settings_form.params || %{}
|
||||
params = Map.put(current_params, "og_image_id", image_id)
|
||||
|
||||
form =
|
||||
socket.assigns.page_struct
|
||||
|> Page.custom_changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:settings_form, form)
|
||||
|> assign(:settings_og_image, image)
|
||||
|> assign(:settings_og_picker_open, false)}
|
||||
end
|
||||
|
||||
def handle_event("clear_og_image", _params, socket) do
|
||||
current_params = socket.assigns.settings_form.params || %{}
|
||||
params = Map.put(current_params, "og_image_id", nil)
|
||||
|
||||
form =
|
||||
socket.assigns.page_struct
|
||||
|> Page.custom_changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:settings_form, form)
|
||||
|> assign(:settings_og_image, nil)}
|
||||
end
|
||||
|
||||
defp assign_settings_form(socket, slug) do
|
||||
if Page.system_slug?(slug) do
|
||||
socket
|
||||
|> assign(:show_settings, false)
|
||||
|> assign(:page_struct, nil)
|
||||
|> assign(:settings_form, nil)
|
||||
|> assign(:settings_og_image, nil)
|
||||
|> assign(:settings_og_picker_open, false)
|
||||
|> assign(:settings_og_picker_images, [])
|
||||
else
|
||||
page_struct = Pages.get_page_struct(slug)
|
||||
|
||||
og_image =
|
||||
if page_struct.og_image_id, do: Media.get_image(page_struct.og_image_id), else: nil
|
||||
|
||||
socket
|
||||
|> assign(:show_settings, false)
|
||||
|> assign(:page_struct, page_struct)
|
||||
|> assign(:settings_form, to_form(Page.custom_changeset(page_struct, %{})))
|
||||
|> assign(:settings_og_image, og_image)
|
||||
|> assign(:settings_og_picker_open, false)
|
||||
|> assign(:settings_og_picker_images, [])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -631,6 +691,88 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
label="Meta description"
|
||||
phx-no-feedback
|
||||
/>
|
||||
<.input
|
||||
field={@settings_form[:meta_robots]}
|
||||
type="select"
|
||||
label="Search engine indexing"
|
||||
options={meta_robots_options()}
|
||||
/>
|
||||
<.input
|
||||
field={@settings_form[:focus_keyword]}
|
||||
label="Focus keyword"
|
||||
placeholder="e.g. handmade prints"
|
||||
/>
|
||||
|
||||
<%!-- Social sharing image --%>
|
||||
<div class="page-settings-og-image">
|
||||
<label class="admin-label">Social sharing image</label>
|
||||
<p class="admin-hint">Shown when this page is shared on social media</p>
|
||||
<input
|
||||
type="hidden"
|
||||
name="page[og_image_id]"
|
||||
value={@settings_og_image && @settings_og_image.id}
|
||||
/>
|
||||
<%= if @settings_og_image do %>
|
||||
<div class="page-settings-og-preview">
|
||||
<img
|
||||
src={media_image_url(@settings_og_image, 400)}
|
||||
alt="Social preview"
|
||||
class="page-settings-og-thumb"
|
||||
/>
|
||||
<div class="page-settings-og-actions">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="show_og_picker"
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="clear_og_image"
|
||||
class="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="show_og_picker"
|
||||
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||
>
|
||||
Select image
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- SEO analysis section --%>
|
||||
<details class="page-settings-seo-preview">
|
||||
<summary class="page-settings-seo-summary">
|
||||
SEO analysis
|
||||
</summary>
|
||||
<div class="page-settings-seo-content">
|
||||
<.seo_checklist page={seo_analysis_page(@settings_form, @blocks)} />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<%!-- SEO preview section --%>
|
||||
<details class="page-settings-seo-preview">
|
||||
<summary class="page-settings-seo-summary">
|
||||
SEO preview
|
||||
</summary>
|
||||
<div class="page-settings-seo-content">
|
||||
<.seo_preview
|
||||
title={seo_preview_title(@settings_form, @site_name)}
|
||||
description={seo_preview_description(@settings_form)}
|
||||
url={seo_preview_url(@settings_form)}
|
||||
og_image={seo_preview_og_image(@settings_og_image)}
|
||||
site_name={@site_name}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="page-settings-row">
|
||||
<.input field={@settings_form[:published]} type="checkbox" label="Published" />
|
||||
<.input
|
||||
@@ -717,6 +859,52 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
search={@image_picker_search}
|
||||
upload={@uploads.image_picker_upload}
|
||||
/>
|
||||
|
||||
<%!-- OG image picker modal --%>
|
||||
<.og_image_picker :if={@settings_og_picker_open} images={@settings_og_picker_images} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp og_image_picker(assigns) do
|
||||
~H"""
|
||||
<div class="admin-modal-backdrop" phx-click="hide_og_picker">
|
||||
<div class="admin-modal admin-modal-lg" phx-click-away="hide_og_picker">
|
||||
<div class="admin-modal-header">
|
||||
<h2>Select social sharing image</h2>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="hide_og_picker"
|
||||
class="admin-modal-close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="admin-modal-hint">
|
||||
Recommended: 1200×630px for best results on social platforms
|
||||
</p>
|
||||
<div class="admin-modal-body">
|
||||
<div class="image-picker-grid">
|
||||
<button
|
||||
:for={image <- @images}
|
||||
type="button"
|
||||
phx-click="pick_og_image"
|
||||
phx-value-image-id={image.id}
|
||||
class="image-picker-item"
|
||||
>
|
||||
<img
|
||||
src={media_image_url(image, 200)}
|
||||
alt={image.alt || ""}
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<%= if @images == [] do %>
|
||||
<p class="image-picker-empty">No images in media library yet</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -864,4 +1052,62 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
|
||||
defp default_nav_items("header"), do: Site.default_header_nav()
|
||||
defp default_nav_items("footer"), do: Site.default_footer_nav()
|
||||
|
||||
defp meta_robots_options do
|
||||
[
|
||||
{"Index this page (default)", "index, follow"},
|
||||
{"Don't index this page", "noindex, follow"},
|
||||
{"Index but don't follow links", "index, nofollow"},
|
||||
{"Don't index or follow links", "noindex, nofollow"}
|
||||
]
|
||||
end
|
||||
|
||||
# SEO preview helpers — extract values from the live form
|
||||
defp seo_preview_title(form, site_name) do
|
||||
title = Phoenix.HTML.Form.input_value(form, :title) || ""
|
||||
if title == "", do: site_name, else: "#{title} · #{site_name}"
|
||||
end
|
||||
|
||||
defp seo_preview_description(form) do
|
||||
Phoenix.HTML.Form.input_value(form, :meta_description) || ""
|
||||
end
|
||||
|
||||
defp seo_preview_url(form) do
|
||||
slug = Phoenix.HTML.Form.input_value(form, :slug) || ""
|
||||
base = BerrypodWeb.Endpoint.url()
|
||||
if slug == "", do: base, else: "#{base}/#{slug}"
|
||||
end
|
||||
|
||||
# Build page map for SEO analysis
|
||||
defp seo_analysis_page(form, blocks) do
|
||||
%{
|
||||
focus_keyword: Phoenix.HTML.Form.input_value(form, :focus_keyword),
|
||||
title: Phoenix.HTML.Form.input_value(form, :title),
|
||||
meta_description: Phoenix.HTML.Form.input_value(form, :meta_description),
|
||||
slug: Phoenix.HTML.Form.input_value(form, :slug),
|
||||
blocks: blocks
|
||||
}
|
||||
end
|
||||
|
||||
defp seo_preview_og_image(nil), do: nil
|
||||
|
||||
defp seo_preview_og_image(image) do
|
||||
media_image_url(image, 400)
|
||||
end
|
||||
|
||||
# Generate a URL for a media library image at the given width
|
||||
defp media_image_url(nil, _width), do: nil
|
||||
|
||||
defp media_image_url(image, width) do
|
||||
if image.is_svg do
|
||||
"/image_cache/#{image.id}.webp"
|
||||
else
|
||||
applicable_width =
|
||||
image.source_width
|
||||
|> Berrypod.Images.Optimizer.applicable_widths()
|
||||
|> Enum.find(&(&1 >= width))
|
||||
|
||||
"/image_cache/#{image.id}-#{applicable_width || width}.webp"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user