194 lines
5.6 KiB
Elixir
194 lines
5.6 KiB
Elixir
|
|
defmodule BerrypodWeb.Components.SeoPreview do
|
|||
|
|
@moduledoc """
|
|||
|
|
SEO preview component showing how pages appear in search results and social cards.
|
|||
|
|
|
|||
|
|
Provides live-updating previews as users edit page titles and descriptions,
|
|||
|
|
with character count indicators showing optimal lengths.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
use Phoenix.Component
|
|||
|
|
|
|||
|
|
@doc """
|
|||
|
|
Renders the full SEO preview panel with Google and social previews.
|
|||
|
|
"""
|
|||
|
|
attr :title, :string, required: true
|
|||
|
|
attr :description, :string, default: ""
|
|||
|
|
attr :url, :string, required: true
|
|||
|
|
attr :og_image, :string, default: nil
|
|||
|
|
attr :site_name, :string, required: true
|
|||
|
|
|
|||
|
|
def seo_preview(assigns) do
|
|||
|
|
~H"""
|
|||
|
|
<div class="seo-preview">
|
|||
|
|
<.google_preview title={@title} description={@description} url={@url} site_name={@site_name} />
|
|||
|
|
<.social_preview
|
|||
|
|
title={@title}
|
|||
|
|
description={@description}
|
|||
|
|
url={@url}
|
|||
|
|
image={@og_image}
|
|||
|
|
site_name={@site_name}
|
|||
|
|
/>
|
|||
|
|
<.character_counts title={@title} description={@description} />
|
|||
|
|
</div>
|
|||
|
|
"""
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
@doc """
|
|||
|
|
Google search result preview mockup.
|
|||
|
|
"""
|
|||
|
|
attr :title, :string, required: true
|
|||
|
|
attr :description, :string, default: ""
|
|||
|
|
attr :url, :string, required: true
|
|||
|
|
attr :site_name, :string, required: true
|
|||
|
|
|
|||
|
|
def google_preview(assigns) do
|
|||
|
|
# Truncate title at ~60 chars, description at ~160 chars
|
|||
|
|
truncated_title = truncate(assigns.title, 60)
|
|||
|
|
truncated_desc = truncate(assigns.description || "", 160)
|
|||
|
|
|
|||
|
|
# Build breadcrumb-style URL
|
|||
|
|
breadcrumb = build_breadcrumb(assigns.url, assigns.site_name)
|
|||
|
|
|
|||
|
|
assigns =
|
|||
|
|
assigns
|
|||
|
|
|> assign(:truncated_title, truncated_title)
|
|||
|
|
|> assign(:truncated_desc, truncated_desc)
|
|||
|
|
|> assign(:breadcrumb, breadcrumb)
|
|||
|
|
|
|||
|
|
~H"""
|
|||
|
|
<div class="seo-preview-section">
|
|||
|
|
<div class="seo-preview-label">Google search preview</div>
|
|||
|
|
<div class="seo-google-preview">
|
|||
|
|
<div class="seo-google-breadcrumb">{@breadcrumb}</div>
|
|||
|
|
<div class="seo-google-title">{@truncated_title}</div>
|
|||
|
|
<div class="seo-google-description">{@truncated_desc}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
"""
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
@doc """
|
|||
|
|
Social media card preview (Facebook/Twitter style).
|
|||
|
|
"""
|
|||
|
|
attr :title, :string, required: true
|
|||
|
|
attr :description, :string, default: ""
|
|||
|
|
attr :url, :string, required: true
|
|||
|
|
attr :image, :string, default: nil
|
|||
|
|
attr :site_name, :string, required: true
|
|||
|
|
|
|||
|
|
def social_preview(assigns) do
|
|||
|
|
truncated_title = truncate(assigns.title, 70)
|
|||
|
|
truncated_desc = truncate(assigns.description || "", 100)
|
|||
|
|
domain = extract_domain(assigns.url)
|
|||
|
|
|
|||
|
|
assigns =
|
|||
|
|
assigns
|
|||
|
|
|> assign(:truncated_title, truncated_title)
|
|||
|
|
|> assign(:truncated_desc, truncated_desc)
|
|||
|
|
|> assign(:domain, domain)
|
|||
|
|
|
|||
|
|
~H"""
|
|||
|
|
<div class="seo-preview-section">
|
|||
|
|
<div class="seo-preview-label">Social card preview</div>
|
|||
|
|
<div class="seo-social-preview">
|
|||
|
|
<div class="seo-social-image">
|
|||
|
|
<%= if @image do %>
|
|||
|
|
<img src={@image} alt="" />
|
|||
|
|
<% else %>
|
|||
|
|
<div class="seo-social-image-placeholder">
|
|||
|
|
<span>No image set</span>
|
|||
|
|
</div>
|
|||
|
|
<% end %>
|
|||
|
|
</div>
|
|||
|
|
<div class="seo-social-content">
|
|||
|
|
<div class="seo-social-domain">{@domain}</div>
|
|||
|
|
<div class="seo-social-title">{@truncated_title}</div>
|
|||
|
|
<div class="seo-social-description">{@truncated_desc}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
"""
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
@doc """
|
|||
|
|
Character count indicators for title and description.
|
|||
|
|
"""
|
|||
|
|
attr :title, :string, required: true
|
|||
|
|
attr :description, :string, default: ""
|
|||
|
|
|
|||
|
|
def character_counts(assigns) do
|
|||
|
|
title_len = String.length(assigns.title || "")
|
|||
|
|
desc_len = String.length(assigns.description || "")
|
|||
|
|
|
|||
|
|
title_status = title_status(title_len)
|
|||
|
|
desc_status = desc_status(desc_len)
|
|||
|
|
|
|||
|
|
assigns =
|
|||
|
|
assigns
|
|||
|
|
|> assign(:title_len, title_len)
|
|||
|
|
|> assign(:desc_len, desc_len)
|
|||
|
|
|> assign(:title_status, title_status)
|
|||
|
|
|> assign(:desc_status, desc_status)
|
|||
|
|
|
|||
|
|
~H"""
|
|||
|
|
<div class="seo-char-counts">
|
|||
|
|
<div class="seo-char-count" data-status={@title_status}>
|
|||
|
|
<span class="seo-char-label">Title</span>
|
|||
|
|
<span class="seo-char-value">{@title_len}/60</span>
|
|||
|
|
<span class="seo-char-indicator"></span>
|
|||
|
|
</div>
|
|||
|
|
<div class="seo-char-count" data-status={@desc_status}>
|
|||
|
|
<span class="seo-char-label">Description</span>
|
|||
|
|
<span class="seo-char-value">{@desc_len}/160</span>
|
|||
|
|
<span class="seo-char-indicator"></span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
"""
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
# Title: green ≤60, yellow 61-70, red >70
|
|||
|
|
defp title_status(len) when len <= 60, do: "good"
|
|||
|
|
defp title_status(len) when len <= 70, do: "warning"
|
|||
|
|
defp title_status(_), do: "error"
|
|||
|
|
|
|||
|
|
# Description: green 120-155, yellow 100-119 or 156-160, red <100 or >160
|
|||
|
|
defp desc_status(len) when len >= 120 and len <= 155, do: "good"
|
|||
|
|
defp desc_status(len) when len >= 100 and len <= 160, do: "warning"
|
|||
|
|
defp desc_status(_), do: "error"
|
|||
|
|
|
|||
|
|
defp truncate(nil, _max), do: ""
|
|||
|
|
defp truncate("", _max), do: ""
|
|||
|
|
|
|||
|
|
defp truncate(text, max) when byte_size(text) > max do
|
|||
|
|
String.slice(text, 0, max - 3) <> "..."
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
defp truncate(text, _max), do: text
|
|||
|
|
|
|||
|
|
defp build_breadcrumb(url, site_name) do
|
|||
|
|
case URI.parse(url) do
|
|||
|
|
%URI{path: path} when is_binary(path) ->
|
|||
|
|
parts =
|
|||
|
|
path
|
|||
|
|
|> String.split("/", trim: true)
|
|||
|
|
|> Enum.take(2)
|
|||
|
|
|
|||
|
|
if parts == [] do
|
|||
|
|
site_name
|
|||
|
|
else
|
|||
|
|
site_name <> " › " <> Enum.join(parts, " › ")
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
_ ->
|
|||
|
|
site_name
|
|||
|
|
end
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
defp extract_domain(url) do
|
|||
|
|
case URI.parse(url) do
|
|||
|
|
%URI{host: host} when is_binary(host) -> host
|
|||
|
|
_ -> "example.com"
|
|||
|
|
end
|
|||
|
|
end
|
|||
|
|
end
|