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:
@@ -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" } },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user