authors: db-driven /authors/[slug] for all 160 contributors

Drops the 540-line hand-curated map and replaces it with a real
bb.author_profiles table + view (author_profiles_with_stats) that
computes first_post_date / total_articles live from
wp_imported_posts. The page now resolves any slug correctly:

- Slug looked up in author_profiles; if no row, the slug is
  titleized ("desert-moon-communications" → "Desert Moon
  Communications") and used as the wp_imported_posts.author_name
  filter, so articles + join date still resolve.
- "Joined ..." line is now the actual MIN(wp_published_at) for that
  author, formatted as Month YYYY. Fixes Desert Moon showing 2020
  when its first post was Nov 2013.
- Role line defaults to "Contributor"; staff/founder roles ride on
  the new role column. Ryan seeded with "Founder of Broadcast Beat"
  in the migration.
- About tab renders a contact card (address / phone / email /
  website) whenever any of those fields exist on the profile row,
  plus a profile-facts panel with location + member-since +
  articles published + role.
- Bio is from the profile row; when empty, page shows
  "No biography has been added yet for {name}" instead of falling
  back to another author's bio.

Bios + contact details for the remaining ~160 authors are populated
by a separate Ollama+web-fetch enrichment job (next commit).
This commit is contained in:
Ryan Salazar
2026-05-20 06:25:08 +00:00
parent fa94e92af5
commit f7c462891f
3 changed files with 521 additions and 418 deletions

View File

@@ -15,27 +15,22 @@ function client() {
});
}
// Slug → list of author_name values that should match. Multiple entries
// are OR'd so name variants ("Ryan Salazar", "Ryan", etc.) all resolve
// to the same author profile.
const SLUG_TO_NAMES: Record<string, string[]> = {
"ryan-salazar": ["Ryan Salazar"],
"james-whitfield": ["James Whitfield"],
"sarah-chen": ["Sarah Chen"],
"elena-vasquez": ["Elena Vasquez"],
"marcus-rivera": ["Marcus Rivera"],
"rachel-kim": ["Rachel Kim"],
"carlos-mendez": ["Carlos Mendez"],
};
function namesForSlug(slug: string): string[] {
if (SLUG_TO_NAMES[slug]) return SLUG_TO_NAMES[slug];
// Fallback: convert "first-last" → "First Last"
const guess = slug
// Title-case a slug as a last-resort display fallback.
function titleizeSlug(slug: string): string {
return slug
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w))
.join(" ");
return [guess];
}
// Format a date for the "Joined March 2014" line.
function fmtJoined(iso: string | null | undefined): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString("en-US", { month: "long", year: "numeric" });
} catch {
return "";
}
}
export async function GET(
@@ -46,17 +41,33 @@ export async function GET(
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get("limit") || "500", 10) || 500, 5000);
const names = namesForSlug(slug);
const sb = client();
// Count first (for accurate article-count badge)
// 1) Pull the profile (with live join-date stats) from the new view.
// If no row exists for this slug, we synthesize a minimum profile
// from author_name = titleized(slug) so the page always renders.
const { data: profileRow } = await sb
.from("author_profiles_with_stats")
.select("*")
.eq("slug", slug)
.maybeSingle();
// 2) Resolve which author_name values count as "this author".
// If we have a stored profile, use its author_names array.
// Otherwise fall back to the titleized slug guess.
const names: string[] =
profileRow?.author_names && profileRow.author_names.length > 0
? profileRow.author_names
: [titleizeSlug(slug)];
// 3) Count + fetch articles by name match.
const { count: total } = await sb
.from("wp_imported_posts")
.select("wp_id", { count: "exact", head: true })
.eq("status", "published")
.in("author_name", names);
const { data } = await sb
const { data: articleRows } = await sb
.from("wp_imported_posts")
.select("wp_id, wp_slug, title, excerpt, category, featured_image, wp_published_at, author_name")
.eq("status", "published")
@@ -64,7 +75,22 @@ export async function GET(
.order("wp_published_at", { ascending: false, nullsFirst: false })
.limit(limit);
const articles = (data || []).map((r: any) => ({
// 4) If no profile row, compute join date directly from posts so the
// page still gets an accurate "Joined" line.
let firstPostDate: string | null = profileRow?.first_post_date ?? null;
if (!firstPostDate) {
const { data: firstRow } = await sb
.from("wp_imported_posts")
.select("wp_published_at")
.eq("status", "published")
.in("author_name", names)
.order("wp_published_at", { ascending: true, nullsFirst: false })
.limit(1)
.maybeSingle();
firstPostDate = firstRow?.wp_published_at ?? null;
}
const articles = (articleRows || []).map((r: any) => ({
slug: r.wp_slug,
title: r.title,
excerpt: r.excerpt || "",
@@ -77,8 +103,42 @@ export async function GET(
readTime: estimateReadTime(r.excerpt || ""),
}));
// 5) Build the response. Display name preference order:
// profile.display_name > first matched author_name > titleized slug
const displayName =
profileRow?.display_name ||
(articleRows && articleRows.length > 0 ? articleRows[0].author_name : null) ||
titleizeSlug(slug);
const profile = {
slug,
display_name: displayName,
role: profileRow?.role || "Contributor",
kind: profileRow?.kind || "contributor",
bio: profileRow?.bio || null,
address_line1: profileRow?.address_line1 || null,
address_line2: profileRow?.address_line2 || null,
city: profileRow?.city || null,
state: profileRow?.state || null,
postal_code: profileRow?.postal_code || null,
country: profileRow?.country || null,
phone: profileRow?.phone || null,
email: profileRow?.email || null,
website: profileRow?.website || null,
twitter: profileRow?.twitter || null,
linkedin: profileRow?.linkedin || null,
facebook: profileRow?.facebook || null,
instagram: profileRow?.instagram || null,
avatar_url: profileRow?.avatar_url || "/assets/images/logo.png",
cover_url: profileRow?.cover_url || "/assets/images/og-image.png",
specialties: profileRow?.specialties || [],
location_text: profileRow?.location_text || null,
first_post_date: firstPostDate,
joined_label: fmtJoined(firstPostDate),
};
return NextResponse.json(
{ slug, names, total: total ?? articles.length, articles },
{ slug, names, total: total ?? articles.length, profile, articles },
{ headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" } },
);
}