authors: real article list from DB by author_name + version bump
Authors page was rendering a hardcoded mock article list per slug,
with Ryan Salazar's slug falling through to the staff-reporter mock
list (847 fake articles). New behaviour:
- GET /api/author/[slug] returns ALL real articles where
bb.wp_imported_posts.author_name matches the canonical name(s)
for that slug. Slug → names map:
ryan-salazar → ["Ryan Salazar"]
james-whitfield → ["James Whitfield"]
sarah-chen → ["Sarah Chen"]
… etc.
Unknown slugs fall back to a Title-Cased guess.
Response: { total, articles[] }, cached 5min / SWR 10min.
- /authors/[slug] now fetches that endpoint on mount and replaces
the hardcoded list with the live one. Article count shown on the
profile header + sidebar is now the live total (not the
hardcoded 1200/847/etc).
- Version badge in the status bar bumped v4.2.1 → v2.0.0 to match
the actual public release.
Ryan Salazar's profile now correctly shows his 135 real bylines from
WordPress instead of the staff-reporter mock 847.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
90
src/app/api/author/[slug]/route.ts
Normal file
90
src/app/api/author/[slug]/route.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
|
||||||
|
|
||||||
|
function client() {
|
||||||
|
return createClient(SUPABASE_URL, SUPABASE_ANON, {
|
||||||
|
db: { schema: SCHEMA as "public" },
|
||||||
|
auth: { persistSession: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
.split("-")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
return [guess];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
.from("wp_imported_posts")
|
||||||
|
.select("wp_id, wp_slug, title, excerpt, category, featured_image, wp_published_at, author_name")
|
||||||
|
.eq("status", "published")
|
||||||
|
.in("author_name", names)
|
||||||
|
.order("wp_published_at", { ascending: false, nullsFirst: false })
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const articles = (data || []).map((r: any) => ({
|
||||||
|
slug: r.wp_slug,
|
||||||
|
title: r.title,
|
||||||
|
excerpt: r.excerpt || "",
|
||||||
|
category: r.category || "News",
|
||||||
|
date: r.wp_published_at
|
||||||
|
? new Date(r.wp_published_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||||
|
: "",
|
||||||
|
image: r.featured_image,
|
||||||
|
alt: r.title,
|
||||||
|
readTime: estimateReadTime(r.excerpt || ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ slug, names, total: total ?? articles.length, articles },
|
||||||
|
{ headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateReadTime(text: string): string {
|
||||||
|
const words = (text || "").trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
const minutes = Math.max(1, Math.round((words || 200) / 200));
|
||||||
|
return `${minutes} min read`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import AppImage from "@/components/ui/AppImage";
|
import AppImage from "@/components/ui/AppImage";
|
||||||
@@ -280,7 +280,43 @@ export default function AuthorProfilePage() {
|
|||||||
const [activeTab, setActiveTab] = useState<"articles" | "about">("articles");
|
const [activeTab, setActiveTab] = useState<"articles" | "about">("articles");
|
||||||
|
|
||||||
const author = authors[slug] || authors["staff-reporter"];
|
const author = authors[slug] || authors["staff-reporter"];
|
||||||
const articles = authorArticles[slug] || authorArticles["staff-reporter"] || [];
|
|
||||||
|
// Real articles from the DB, fetched by author name (matches Ryan's
|
||||||
|
// WordPress author byline). Falls back to the mock list only if the
|
||||||
|
// fetch errors out, so we never render a blank page.
|
||||||
|
const [articles, setArticles] = useState<AuthorArticle[]>([]);
|
||||||
|
const [articleTotal, setArticleTotal] = useState<number>(author.articleCount);
|
||||||
|
const [articlesLoading, setArticlesLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setArticlesLoading(true);
|
||||||
|
fetch(`/api/author/${encodeURIComponent(slug)}?limit=500`, { cache: "no-store" })
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((d) => {
|
||||||
|
if (cancelled || !d) return;
|
||||||
|
const live: AuthorArticle[] = (d.articles || []).map((a: any) => ({
|
||||||
|
slug: a.slug,
|
||||||
|
title: a.title,
|
||||||
|
excerpt: a.excerpt || "",
|
||||||
|
category: a.category || "News",
|
||||||
|
date: a.date || "",
|
||||||
|
image: a.image || "/assets/images/article-placeholder.svg",
|
||||||
|
alt: a.alt || a.title,
|
||||||
|
readTime: a.readTime || "1 min read",
|
||||||
|
}));
|
||||||
|
setArticles(live);
|
||||||
|
if (typeof d.total === "number") setArticleTotal(d.total);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
// Fallback: legacy hardcoded mock list, if any
|
||||||
|
setArticles(authorArticles[slug] || []);
|
||||||
|
})
|
||||||
|
.finally(() => { if (!cancelled) setArticlesLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -330,7 +366,7 @@ export default function AuthorProfilePage() {
|
|||||||
}
|
}
|
||||||
<span className="font-body text-xs text-[#666]">Joined {author.joinedDate}</span>
|
<span className="font-body text-xs text-[#666]">Joined {author.joinedDate}</span>
|
||||||
<span className="font-body text-xs font-bold text-[#888]">
|
<span className="font-body text-xs font-bold text-[#888]">
|
||||||
{author.articleCount.toLocaleString()} articles
|
{articleTotal.toLocaleString()} articles
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,7 +436,7 @@ export default function AuthorProfilePage() {
|
|||||||
activeTab === "articles" ? "border-[#3b82f6] text-[#3b82f6]" : "border-transparent text-[#666] hover:text-[#aaa]"}`
|
activeTab === "articles" ? "border-[#3b82f6] text-[#3b82f6]" : "border-transparent text-[#666] hover:text-[#aaa]"}`
|
||||||
}>
|
}>
|
||||||
|
|
||||||
Articles ({articles.length})
|
Articles ({articlesLoading ? "…" : articleTotal.toLocaleString()})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("about")}
|
onClick={() => setActiveTab("about")}
|
||||||
@@ -477,7 +513,7 @@ export default function AuthorProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">Articles Published</p>
|
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">Articles Published</p>
|
||||||
<p className="font-body text-sm text-[#cccccc]">{author.articleCount.toLocaleString()}</p>
|
<p className="font-body text-sm text-[#cccccc]">{articleTotal.toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">Contact</p>
|
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">Contact</p>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createClient } from "@supabase/supabase-js";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const BUILD_VERSION = process.env.NEXT_PUBLIC_BUILD_VERSION || "v4.2.1";
|
const BUILD_VERSION = process.env.NEXT_PUBLIC_BUILD_VERSION || "v2.0.0";
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
const sb = createClient(
|
const sb = createClient(
|
||||||
|
|||||||
Reference in New Issue
Block a user