Skip to content

Commit cffc0e0

Browse files
committed
Add admin UI for SEO features, redirects, authors, link health, measurement
SEO Tab (Page Editor): - Dedicated SEO tab with meta description, OG title/description/image, canonical URL, robots, twitter card, page type fields - Live SERP preview (Google search result mockup) - Social preview (Facebook/LinkedIn card) - Character counters with color coding (green/yellow/red) - SEO completeness score (traffic light indicator) SEO Audit Page: - Site-wide dashboard: total pages, good/needs-work/poor breakdown - Per-page scoring with issue detection (missing title, description, etc) - "Fix" links to SEO tab for each page Schema Editor Enhancements: - Template insertion buttons for Article, FAQPage, Product, HowTo Redirect Manager: - CRUD for URL redirects with source/destination/status code - Search by path - Hit count display - Create/edit form with status code selector (301/302/307/308) Author Manager: - CRUD for content authors with name, slug, bio, job title, avatar, credentials, and external profile URLs (sameAs) Link Health Dashboard: - Orphan pages tab (pages with zero inbound internal links) - Broken links tab (links to non-existent paths) - Summary cards with counts Measurement Dashboard: - SEO metrics snapshot capture (manual "Take Snapshot" button) - Historical trend table (pages, descriptions, OG images, stale count) - Date range selector (7/30/90 days) - Summary cards showing latest metrics Client Content API: - Wrapper functions for all new Content API operations
1 parent 2ab5265 commit cffc0e0

11 files changed

Lines changed: 1217 additions & 0 deletions

File tree

lib/beacon/live_admin/client/content.ex

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,35 @@ defmodule Beacon.LiveAdmin.Client.Content do
338338
def delete_page_query(site, page_query) do
339339
call(site, Beacon.Content, :delete_page_query, [site, page_query])
340340
end
341+
342+
# Redirects
343+
344+
def list_redirects(site, opts \\ []), do: call(site, Beacon.Content, :list_redirects, [site, opts])
345+
def get_redirect(site, id), do: call(site, Beacon.Content, :get_redirect, [site, id])
346+
def create_redirect(site, attrs), do: call(site, Beacon.Content, :create_redirect, [attrs])
347+
def update_redirect(site, redirect, attrs), do: call(site, Beacon.Content, :update_redirect, [redirect, attrs])
348+
def delete_redirect(site, redirect), do: call(site, Beacon.Content, :delete_redirect, [redirect])
349+
350+
# Authors
351+
352+
def list_authors(site, opts \\ []), do: call(site, Beacon.Content, :list_authors, [site, opts])
353+
def get_author(site, id), do: call(site, Beacon.Content, :get_author, [site, id])
354+
def create_author(site, attrs), do: call(site, Beacon.Content, :create_author, [attrs])
355+
def update_author(site, redirect, attrs), do: call(site, Beacon.Content, :update_author, [redirect, attrs])
356+
def delete_author(site, redirect), do: call(site, Beacon.Content, :delete_author, [redirect])
357+
358+
# Link Health
359+
360+
def list_orphan_pages(site), do: call(site, Beacon.Content, :list_orphan_pages, [site])
361+
def list_broken_links(site), do: call(site, Beacon.Content, :list_broken_links, [site])
362+
363+
# Measurement
364+
365+
def take_seo_snapshot(site), do: call(site, Beacon.Content, :take_seo_snapshot, [site])
366+
def list_seo_snapshots(site, opts \\ []), do: call(site, Beacon.Content, :list_seo_snapshots, [site, opts])
367+
368+
# Content Freshness
369+
370+
def mark_page_updated(site, page), do: call(site, Beacon.Content, :mark_page_updated, [page])
371+
def list_stale_pages(site, days \\ 90), do: call(site, Beacon.Content, :list_stale_pages, [site, days])
341372
end

lib/beacon/live_admin/components/admin_components.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ defmodule Beacon.LiveAdmin.AdminComponents do
225225
<li>
226226
<.link patch={beacon_live_admin_path(@socket, @site, "/pages/#{@page_id}")} class={if(@current_action == :edit, do: @active_class, else: @regular_class)}>Page</.link>
227227
</li>
228+
<li>
229+
<.link patch={beacon_live_admin_path(@socket, @site, "/pages/#{@page_id}/seo")} class={if(@current_action == :seo, do: @active_class, else: @regular_class)}>SEO</.link>
230+
</li>
228231
<li>
229232
<.link patch={beacon_live_admin_path(@socket, @site, "/pages/#{@page_id}/meta_tags")} class={if(@current_action == :meta_tags, do: @active_class, else: @regular_class)}>Meta Tags</.link>
230233
</li>
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
defmodule Beacon.LiveAdmin.AuthorManagerLive do
2+
@moduledoc false
3+
4+
use Beacon.LiveAdmin.PageBuilder
5+
alias Beacon.LiveAdmin.Client.Content
6+
7+
@impl true
8+
def menu_link("/authors", action) when action in [:index, :edit], do: {:root, "Authors"}
9+
def menu_link(_, _), do: :skip
10+
11+
@impl true
12+
def handle_params(params, _url, socket) do
13+
site = socket.assigns.beacon_page.site
14+
authors = Content.list_authors(site)
15+
16+
{:noreply,
17+
socket
18+
|> assign(:authors, authors)
19+
|> assign(:show_form, false)
20+
|> assign(:editing, nil)
21+
|> assign(:form_data, default_form())
22+
|> assign(page_title: "Authors")}
23+
end
24+
25+
defp default_form do
26+
%{"name" => "", "slug" => "", "bio" => "", "job_title" => "", "avatar_url" => "", "credentials" => "", "same_as" => ""}
27+
end
28+
29+
@impl true
30+
def handle_event("new", _, socket) do
31+
{:noreply, assign(socket, show_form: true, editing: nil, form_data: default_form())}
32+
end
33+
34+
def handle_event("cancel", _, socket) do
35+
{:noreply, assign(socket, show_form: false, editing: nil)}
36+
end
37+
38+
def handle_event("edit", %{"id" => id}, socket) do
39+
site = socket.assigns.beacon_page.site
40+
author = Content.get_author(site, id)
41+
42+
{:noreply,
43+
assign(socket,
44+
show_form: true,
45+
editing: author,
46+
form_data: %{
47+
"name" => author.name || "",
48+
"slug" => author.slug || "",
49+
"bio" => author.bio || "",
50+
"job_title" => author.job_title || "",
51+
"avatar_url" => author.avatar_url || "",
52+
"credentials" => author.credentials || "",
53+
"same_as" => Enum.join(author.same_as || [], "\n")
54+
}
55+
)}
56+
end
57+
58+
def handle_event("validate", %{"author" => params}, socket) do
59+
{:noreply, assign(socket, :form_data, params)}
60+
end
61+
62+
def handle_event("save", %{"author" => params}, socket) do
63+
site = socket.assigns.beacon_page.site
64+
65+
same_as = params["same_as"] |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == ""))
66+
67+
attrs = %{
68+
"site" => site,
69+
"name" => params["name"],
70+
"slug" => params["slug"],
71+
"bio" => params["bio"],
72+
"job_title" => params["job_title"],
73+
"avatar_url" => params["avatar_url"],
74+
"credentials" => params["credentials"],
75+
"same_as" => same_as
76+
}
77+
78+
result =
79+
case socket.assigns.editing do
80+
nil -> Content.create_author(site, attrs)
81+
author -> Content.update_author(site, author, attrs)
82+
end
83+
84+
case result do
85+
{:ok, _} ->
86+
authors = Content.list_authors(site)
87+
{:noreply, socket |> assign(authors: authors, show_form: false, editing: nil) |> put_flash(:info, "Author saved")}
88+
89+
{:error, _} ->
90+
{:noreply, put_flash(socket, :error, "Failed to save author")}
91+
end
92+
end
93+
94+
def handle_event("delete", %{"id" => id}, socket) do
95+
site = socket.assigns.beacon_page.site
96+
author = Content.get_author(site, id)
97+
98+
case Content.delete_author(site, author) do
99+
{:ok, _} ->
100+
authors = Content.list_authors(site)
101+
{:noreply, socket |> assign(authors: authors) |> put_flash(:info, "Author deleted")}
102+
103+
{:error, _} ->
104+
{:noreply, put_flash(socket, :error, "Failed to delete author")}
105+
end
106+
end
107+
108+
@impl true
109+
def render(assigns) do
110+
~H"""
111+
<div class="mx-auto max-w-6xl py-6 px-4">
112+
<div class="flex items-center justify-between mb-6">
113+
<h1 class="text-2xl font-bold text-gray-900">Authors</h1>
114+
<button phx-click="new" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
115+
New Author
116+
</button>
117+
</div>
118+
119+
<%= if @show_form do %>
120+
<div class="bg-white border rounded-lg p-6 mb-6">
121+
<h2 class="text-lg font-medium mb-4"><%= if @editing, do: "Edit Author", else: "New Author" %></h2>
122+
<form phx-submit="save" phx-change="validate" class="space-y-4">
123+
<div class="grid grid-cols-2 gap-4">
124+
<div>
125+
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
126+
<input type="text" name="author[name]" value={@form_data["name"]} class="w-full rounded-md border-gray-300 text-sm" />
127+
</div>
128+
<div>
129+
<label class="block text-sm font-medium text-gray-700 mb-1">Slug</label>
130+
<input type="text" name="author[slug]" value={@form_data["slug"]} placeholder="jane-doe" class="w-full rounded-md border-gray-300 text-sm" />
131+
</div>
132+
</div>
133+
<div>
134+
<label class="block text-sm font-medium text-gray-700 mb-1">Job Title</label>
135+
<input type="text" name="author[job_title]" value={@form_data["job_title"]} class="w-full rounded-md border-gray-300 text-sm" />
136+
</div>
137+
<div>
138+
<label class="block text-sm font-medium text-gray-700 mb-1">Bio</label>
139+
<textarea name="author[bio]" rows="3" class="w-full rounded-md border-gray-300 text-sm"><%= @form_data["bio"] %></textarea>
140+
</div>
141+
<div>
142+
<label class="block text-sm font-medium text-gray-700 mb-1">Avatar URL</label>
143+
<input type="text" name="author[avatar_url]" value={@form_data["avatar_url"]} class="w-full rounded-md border-gray-300 text-sm" />
144+
</div>
145+
<div>
146+
<label class="block text-sm font-medium text-gray-700 mb-1">Credentials</label>
147+
<textarea name="author[credentials]" rows="2" class="w-full rounded-md border-gray-300 text-sm"><%= @form_data["credentials"] %></textarea>
148+
</div>
149+
<div>
150+
<label class="block text-sm font-medium text-gray-700 mb-1">External Profiles (one URL per line)</label>
151+
<textarea name="author[same_as]" rows="3" placeholder="https://linkedin.com/in/jane-doe&#10;https://twitter.com/janedoe" class="w-full rounded-md border-gray-300 text-sm"><%= @form_data["same_as"] %></textarea>
152+
</div>
153+
<div class="flex gap-2 pt-2">
154+
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700">Save</button>
155+
<button type="button" phx-click="cancel" class="px-4 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200">Cancel</button>
156+
</div>
157+
</form>
158+
</div>
159+
<% end %>
160+
161+
<div class="bg-white rounded-lg border overflow-hidden">
162+
<table class="min-w-full divide-y divide-gray-200">
163+
<thead class="bg-gray-50">
164+
<tr>
165+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
166+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Slug</th>
167+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Job Title</th>
168+
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
169+
</tr>
170+
</thead>
171+
<tbody class="divide-y divide-gray-200">
172+
<%= for author <- @authors do %>
173+
<tr>
174+
<td class="px-4 py-3 text-sm font-medium text-gray-900"><%= author.name %></td>
175+
<td class="px-4 py-3 text-sm font-mono text-gray-600"><%= author.slug %></td>
176+
<td class="px-4 py-3 text-sm text-gray-600"><%= author.job_title %></td>
177+
<td class="px-4 py-3 text-right space-x-2">
178+
<button phx-click="edit" phx-value-id={author.id} class="text-indigo-600 hover:text-indigo-900 text-sm">Edit</button>
179+
<button phx-click="delete" phx-value-id={author.id} data-confirm="Delete this author?" class="text-red-600 hover:text-red-900 text-sm">Delete</button>
180+
</td>
181+
</tr>
182+
<% end %>
183+
<%= if @authors == [] do %>
184+
<tr>
185+
<td colspan="4" class="px-4 py-8 text-center text-sm text-gray-500">No authors created yet</td>
186+
</tr>
187+
<% end %>
188+
</tbody>
189+
</table>
190+
</div>
191+
</div>
192+
"""
193+
end
194+
end
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
defmodule Beacon.LiveAdmin.LinkHealthLive do
2+
@moduledoc false
3+
4+
use Beacon.LiveAdmin.PageBuilder
5+
alias Beacon.LiveAdmin.Client.Content
6+
7+
@impl true
8+
def menu_link("/link_health", :index), do: {:root, "Link Health"}
9+
def menu_link(_, _), do: :skip
10+
11+
@impl true
12+
def handle_params(_params, _url, socket) do
13+
site = socket.assigns.beacon_page.site
14+
orphans = Content.list_orphan_pages(site)
15+
broken = Content.list_broken_links(site)
16+
17+
{:noreply,
18+
socket
19+
|> assign(:orphans, orphans)
20+
|> assign(:broken_links, broken)
21+
|> assign(:tab, "orphans")
22+
|> assign(page_title: "Link Health")}
23+
end
24+
25+
@impl true
26+
def handle_event("tab", %{"tab" => tab}, socket) do
27+
{:noreply, assign(socket, :tab, tab)}
28+
end
29+
30+
@impl true
31+
def render(assigns) do
32+
~H"""
33+
<div class="mx-auto max-w-6xl py-6 px-4">
34+
<h1 class="text-2xl font-bold text-gray-900 mb-6">Link Health</h1>
35+
36+
<div class="grid grid-cols-2 gap-4 mb-8">
37+
<div class={"rounded-lg border p-4 text-center cursor-pointer #{if @tab == "orphans", do: "bg-yellow-50 border-yellow-300", else: "bg-white"}"} phx-click="tab" phx-value-tab="orphans">
38+
<div class="text-2xl font-bold text-yellow-600"><%= length(@orphans) %></div>
39+
<div class="text-xs text-gray-500 uppercase">Orphan Pages</div>
40+
<div class="text-xs text-gray-400 mt-1">No inbound links</div>
41+
</div>
42+
<div class={"rounded-lg border p-4 text-center cursor-pointer #{if @tab == "broken", do: "bg-red-50 border-red-300", else: "bg-white"}"} phx-click="tab" phx-value-tab="broken">
43+
<div class="text-2xl font-bold text-red-600"><%= length(@broken_links) %></div>
44+
<div class="text-xs text-gray-500 uppercase">Broken Links</div>
45+
<div class="text-xs text-gray-400 mt-1">Target not found</div>
46+
</div>
47+
</div>
48+
49+
<%= if @tab == "orphans" do %>
50+
<div class="bg-white rounded-lg border overflow-hidden">
51+
<table class="min-w-full divide-y divide-gray-200">
52+
<thead class="bg-gray-50">
53+
<tr>
54+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Path</th>
55+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
56+
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Action</th>
57+
</tr>
58+
</thead>
59+
<tbody class="divide-y divide-gray-200">
60+
<%= for page <- @orphans do %>
61+
<tr class="bg-yellow-50">
62+
<td class="px-4 py-3 text-sm font-mono text-gray-600"><%= page.path %></td>
63+
<td class="px-4 py-3 text-sm text-gray-900 max-w-xs truncate"><%= page.title %></td>
64+
<td class="px-4 py-3 text-right">
65+
<.link patch={beacon_live_admin_path(@socket, @beacon_page.site, "/pages/#{page.id}")} class="text-indigo-600 hover:text-indigo-900 text-sm">Edit</.link>
66+
</td>
67+
</tr>
68+
<% end %>
69+
<%= if @orphans == [] do %>
70+
<tr><td colspan="3" class="px-4 py-8 text-center text-sm text-green-600">No orphan pages found</td></tr>
71+
<% end %>
72+
</tbody>
73+
</table>
74+
</div>
75+
<% end %>
76+
77+
<%= if @tab == "broken" do %>
78+
<div class="bg-white rounded-lg border overflow-hidden">
79+
<table class="min-w-full divide-y divide-gray-200">
80+
<thead class="bg-gray-50">
81+
<tr>
82+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Source Page</th>
83+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Broken Target</th>
84+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Anchor Text</th>
85+
</tr>
86+
</thead>
87+
<tbody class="divide-y divide-gray-200">
88+
<%= for link <- @broken_links do %>
89+
<tr class="bg-red-50">
90+
<td class="px-4 py-3 text-sm font-mono text-gray-600"><%= link.source_page_id %></td>
91+
<td class="px-4 py-3 text-sm font-mono text-red-600"><%= link.target_path %></td>
92+
<td class="px-4 py-3 text-sm text-gray-600"><%= link.anchor_text %></td>
93+
</tr>
94+
<% end %>
95+
<%= if @broken_links == [] do %>
96+
<tr><td colspan="3" class="px-4 py-8 text-center text-sm text-green-600">No broken links found</td></tr>
97+
<% end %>
98+
</tbody>
99+
</table>
100+
</div>
101+
<% end %>
102+
</div>
103+
"""
104+
end
105+
end

0 commit comments

Comments
 (0)