diff --git a/src/app/api/author/[slug]/route.ts b/src/app/api/author/[slug]/route.ts index 9624117..f079a8a 100644 --- a/src/app/api/author/[slug]/route.ts +++ b/src/app/api/author/[slug]/route.ts @@ -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 = { - "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" } }, ); } diff --git a/src/app/authors/[slug]/page.tsx b/src/app/authors/[slug]/page.tsx index 462043a..81fd3ac 100644 --- a/src/app/authors/[slug]/page.tsx +++ b/src/app/authors/[slug]/page.tsx @@ -10,20 +10,29 @@ import { ADS_728X90, shuffle } from "@/lib/ads"; interface AuthorProfile { slug: string; - name: string; - title: string; - avatar: string; - coverImage: string; - bio: string; - location: string; - joinedDate: string; - articleCount: number; + display_name: string; + role: string; + kind: string; + bio: string | null; + address_line1: string | null; + address_line2: string | null; + city: string | null; + state: string | null; + postal_code: string | null; + country: string | null; + phone: string | null; + email: string | null; + website: string | null; + twitter: string | null; + linkedin: string | null; + facebook: string | null; + instagram: string | null; + avatar_url: string; + cover_url: string; specialties: string[]; - social: { - twitter?: string; - linkedin?: string; - email?: string; - }; + location_text: string | null; + first_post_date: string | null; + joined_label: string; } interface AuthorArticle { @@ -32,270 +41,47 @@ interface AuthorArticle { excerpt: string; category: string; date: string; - image: string; + image: string | null; alt: string; readTime: string; } -const authors: Record = { - "ryan-salazar": { - slug: "ryan-salazar", - name: "Ryan Salazar", - title: "Founder of Broadcast Beat", - avatar: "/assets/images/logo.png", - coverImage: "/assets/images/og-image.png", - bio: "Ryan Salazar is the founder of Broadcast Beat Magazine and the Executive Producer of NAB Show LIVE for the National Association of Broadcasters. With more than 20 years in audio, visual, and broadcast technology, Ryan began his career in 1997 as an audio engineer and producer/director at ProComm Studios, then spent seven years as Chief Technology Officer at SunSpots Productions running all AV and IT operations. In 2005 he launched AV Beat to cover the audio-visual industry, and in 2014 transitioned to SMPTE LIVE as Executive Producer and on-camera talent, interviewing industry luminaries including James Cameron, Douglas Trumbull, Billy Zane, and Richard Edlund. Ryan founded Broadcast Beat in December 2013 to give broadcast and production engineers a dedicated trade publication focused on the engineering side of the industry — live production, IP workflows, streaming, cloud, audio, and post. In 2020 he also became President and CTO of Broadcast Beat Studios, a production company based in South Florida. Today he continues to lead Broadcast Beat alongside Relevant Media Properties' growing portfolio of trade publications.", - location: "United States", - joinedDate: "December 2013", - articleCount: 1200, - specialties: ["Broadcast Engineering", "Live Production", "Industry Strategy", "Editorial Direction"], - social: { - twitter: "https://twitter.com/broadcastbeat", - linkedin: "https://linkedin.com/company/broadcastbeat", - email: "ryan.salazar@relevantmediaproperties.com" - } - }, - "staff-reporter": { - slug: "staff-reporter", - name: "Broadcast Beat", - title: "Broadcast Beat Editorial Team", - avatar: "/assets/images/article-placeholder.svg", - coverImage: "/assets/images/article-placeholder.svg", - bio: "The Broadcast Beat editorial team covers breaking news and product announcements from across the broadcast technology industry. Our team of experienced journalists and industry insiders brings you the latest developments from NAB, IBC, and beyond.", - location: "Global", - joinedDate: "December 2013", - articleCount: 847, - specialties: ["Breaking News", "Product Launches", "Industry Events", "Press Releases"], - social: { - twitter: "https://twitter.com/broadcastbeat", - linkedin: "https://linkedin.com/company/broadcastbeat", - email: "editorial@broadcastbeat.com" - } - }, - "james-whitfield": { - slug: "james-whitfield", - name: "James Whitfield", - title: "Senior Technology Correspondent", - avatar: "/assets/images/article-placeholder.svg", - coverImage: "/assets/images/article-placeholder.svg", - bio: "James Whitfield is a senior technology correspondent with over 15 years of experience covering the broadcast and media technology industry. He specializes in ad tech, monetization strategies, and the business of broadcasting. James has interviewed hundreds of industry leaders and covered every major broadcast technology event since 2010.", - location: "New York, NY", - joinedDate: "March 2018", - articleCount: 312, - specialties: ["Ad Technology", "Monetization", "Streaming", "Business Strategy"], - social: { - twitter: "https://twitter.com/jwhitfield", - linkedin: "https://linkedin.com/in/jameswhitfield", - email: "james@broadcastbeat.com" - } - }, - "sarah-chen": { - slug: "sarah-chen", - name: "Sarah Chen", - title: "Audio Technology Editor", - avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200&h=200&fit=crop&crop=face", - coverImage: "https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=1200&h=400&fit=crop", - bio: "Sarah Chen is Broadcast Beat's audio technology editor, bringing deep expertise in professional audio systems, live sound, and broadcast audio workflows. With a background in audio engineering and a decade of industry journalism, Sarah provides authoritative coverage of the audio technology landscape.", - location: "Los Angeles, CA", - joinedDate: "June 2019", - articleCount: 198, - specialties: ["Audio Technology", "Live Production", "Mixing Consoles", "IP Audio"], - social: { - twitter: "https://twitter.com/sarahchentech", - linkedin: "https://linkedin.com/in/sarahchen", - email: "sarah@broadcastbeat.com" - } - }, - "elena-vasquez": { - slug: "elena-vasquez", - name: "Elena Vasquez", - title: "Features Editor", - avatar: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200&h=200&fit=crop&crop=face", - coverImage: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=1200&h=400&fit=crop", - bio: "Elena Vasquez is Broadcast Beat's features editor, specializing in long-form analysis of industry trends, emerging technologies, and the strategic challenges facing media organizations. Her work explores the intersection of technology, business, and creativity in the broadcast industry.", - location: "London, UK", - joinedDate: "September 2017", - articleCount: 156, - specialties: ["Industry Analysis", "AI & Automation", "Content Strategy", "Media Intelligence"], - social: { - twitter: "https://twitter.com/elenavasquez", - linkedin: "https://linkedin.com/in/elenavasquez", - email: "elena@broadcastbeat.com" - } - }, - "rachel-kim": { - slug: "rachel-kim", - name: "Rachel Kim", - title: "Technology Analyst", - avatar: "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?w=200&h=200&fit=crop&crop=face", - coverImage: "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200&h=400&fit=crop", - bio: "Rachel Kim is a technology analyst at Broadcast Beat, focusing on data-driven coverage of storage technologies, media asset management, and cloud infrastructure. Her analytical approach brings quantitative rigor to technology coverage, helping readers understand the business implications of technical decisions.", - location: "San Francisco, CA", - joinedDate: "February 2021", - articleCount: 89, - specialties: ["Storage Technology", "Cloud Infrastructure", "MAM Systems", "Industry Data"], - social: { - twitter: "https://twitter.com/rachelkimtech", - linkedin: "https://linkedin.com/in/rachelkim", - email: "rachel@broadcastbeat.com" - } - }, - "marcus-rivera": { - slug: "marcus-rivera", - name: "Marcus Rivera", - title: "Sports Broadcasting Correspondent", - avatar: "/assets/images/article-placeholder.svg", - coverImage: "/assets/images/article-placeholder.svg", - bio: "Marcus Rivera covers sports broadcasting technology for Broadcast Beat, with a particular focus on live production, fan engagement, and the evolving landscape of sports media rights. He brings a unique perspective that combines technical knowledge with an understanding of the sports media business.", - location: "Miami, FL", - joinedDate: "April 2020", - articleCount: 134, - specialties: ["Sports Broadcasting", "Live Production", "Fan Engagement", "Streaming"], - social: { - twitter: "https://twitter.com/marcusrivera", - linkedin: "https://linkedin.com/in/marcusrivera", - email: "marcus@broadcastbeat.com" - } - } -}; +function combineLocation(p: AuthorProfile): string { + const parts = [p.city, p.state, p.country].filter(Boolean); + if (parts.length > 0) return parts.join(", "); + return p.location_text || ""; +} -const authorArticles: Record = { - "staff-reporter": [ - { - slug: "telestream-expands-cloud-services-up", - title: "Telestream Expands Its Cloud Services with the Introduction of UP", - excerpt: "New cloud-native platform extends Telestream Cloud Services to support Global Ingest, automation, review, and real-time monitoring.", - category: "BROADCAST", - date: "March 11, 2026", - image: "/assets/images/article-placeholder.svg", - alt: "Cloud services infrastructure for broadcast production environments", - readTime: "4 min read" - }, - { - slug: "emergent-launches-fusion-platform", - title: "Emergent Launches Fusion: The 'Interactive Anything' Platform Transforming Data-Driven Storytelling", - excerpt: "No-code application builder designed to transform complex live data into immersive interactive storytelling experiences.", - category: "BROADCAST", - date: "March 8, 2026", - image: "/assets/images/article-placeholder.svg", - alt: "Interactive data storytelling platform for broadcast and enterprise use", - readTime: "3 min read" - }, - { - slug: "globalm-distributed-video-gateway", - title: "GlobalM Introduces Distributed Video Gateway Architecture", - excerpt: "New distributed architecture addresses the growing demand for scalable, resilient live IP transport solutions.", - category: "BROADCAST", - date: "March 7, 2026", - image: "/assets/images/article-placeholder.svg", - alt: "Network infrastructure for distributed video gateway IP transport system", - readTime: "3 min read" - }], - - "james-whitfield": [ - { - slug: "operative-launches-aos-configuration", - title: "Operative Launches AOS Configuration for Digital-First Monetization", - excerpt: "Enterprise-grade intelligent ad management built to scale digital-first revenue across every channel.", - category: "BROADCAST", - date: "March 11, 2026", - image: "/assets/images/article-placeholder.svg", - alt: "Digital monetization analytics dashboard for broadcast advertising", - readTime: "5 min read" - }, - { - slug: "viaccess-orca-nab-2026", - title: "Cost Savings, Scalability, and Smarter Monetization: Viaccess-Orca Brings Efficiency to NAB 2026", - excerpt: "Purpose-driven solutions designed to help broadcasters reduce operational costs while improving content monetization.", - category: "BROADCAST", - date: "March 5, 2026", - image: "/assets/images/article-placeholder.svg", - alt: "Broadcast technology monetization solutions at NAB Show 2026", - readTime: "4 min read" - }], - - "sarah-chen": [ - { - slug: "calrec-redefines-broadcast-workflows-nab-2026", - title: "Calrec Redefines Broadcast Workflows at NAB 2026 with its Most Powerful Audio Lineup Yet", - excerpt: "The broadcast industry is going through a rapid evolution that's signalling a more collaborative and location-independent future.", - category: "BROADCAST", - date: "March 10, 2026", - image: "/assets/images/article-placeholder.svg", - alt: "Calrec audio mixing console for broadcast production at NAB 2026", - readTime: "6 min read" - }], - - "elena-vasquez": [ - { - slug: "from-metadata-to-meaning-semantic-intelligence", - title: "From Metadata to Meaning: Why Semantic Intelligence Is Media's Next Competitive Advantage", - excerpt: "Strategic conversations about content performance and audience intelligence are shifting the way media organizations approach their metadata strategies.", - category: "FEATURED", - date: "March 9, 2026", - image: "/assets/images/article-placeholder.svg", - alt: "Media metadata intelligence and content analytics visualization", - readTime: "8 min read" - }, - { - slug: "mediagenix-title-management", - title: "Mediagenix Title Management Accelerates Content Monetization Through Advanced Semantic Intelligence", - excerpt: "Advancing Semantic Intelligence capabilities to help studios and networks unlock greater value from their content libraries.", - category: "POST PRODUCTION", - date: "March 9, 2026", - image: "/assets/images/article-placeholder.svg", - alt: "Content management system for media title management and monetization", - readTime: "5 min read" - }], - - "rachel-kim": [ - { - slug: "video-is-king-2026-iconik-media-stats", - title: "Video is King: 2026 Iconik Media Stats Report Finds Video Consumes 64% of Storage Needs", - excerpt: "Annual media statistics report reveals video's growing dominance in enterprise storage requirements.", - category: "FEATURED", - date: "March 6, 2026", - image: "/assets/images/article-placeholder.svg", - alt: "Video storage statistics and media asset management data visualization", - readTime: "5 min read" - }], - - "marcus-rivera": [ - { - slug: "ease-live-premier-padel-red-bull-tv", - title: "Ease Live Powers Interactive Premier Padel Experiences on Red Bull TV as Viewership Surges 30%", - excerpt: "Real-time fan engagement enhances viewing and accelerates growth of the world's fastest-growing racket sport.", - category: "BROADCAST", - date: "March 10, 2026", - image: "/assets/images/article-placeholder.svg", - alt: "Interactive sports broadcast experience on streaming platform", - readTime: "4 min read" - }] - -}; +function combineAddress(p: AuthorProfile): string[] { + const lines: string[] = []; + if (p.address_line1) lines.push(p.address_line1); + if (p.address_line2) lines.push(p.address_line2); + const cityLine = [p.city, p.state].filter(Boolean).join(", "); + const tail = [cityLine, p.postal_code].filter(Boolean).join(" "); + if (tail) lines.push(tail); + if (p.country) lines.push(p.country); + return lines; +} export default function AuthorProfilePage() { const params = useParams(); const slug = params?.slug as string; const [activeTab, setActiveTab] = useState<"articles" | "about">("articles"); - const author = authors[slug] || authors["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 [profile, setProfile] = useState(null); const [articles, setArticles] = useState([]); - const [articleTotal, setArticleTotal] = useState(author.articleCount); - const [articlesLoading, setArticlesLoading] = useState(true); + const [articleTotal, setArticleTotal] = useState(0); + const [loading, setLoading] = useState(true); useEffect(() => { if (!slug) return; let cancelled = false; - setArticlesLoading(true); + setLoading(true); fetch(`/api/author/${encodeURIComponent(slug)}?limit=500`, { cache: "no-store" }) - .then((r) => r.ok ? r.json() : null) + .then((r) => (r.ok ? r.json() : null)) .then((d) => { if (cancelled || !d) return; + setProfile(d.profile as AuthorProfile); const live: AuthorArticle[] = (d.articles || []).map((a: any) => ({ slug: a.slug, title: a.title, @@ -310,168 +96,189 @@ export default function AuthorProfilePage() { if (typeof d.total === "number") setArticleTotal(d.total); }) .catch(() => { - if (cancelled) return; - // Fallback: legacy hardcoded mock list, if any - setArticles(authorArticles[slug] || []); + // network/API error — let the page render its empty state below. }) - .finally(() => { if (!cancelled) setArticlesLoading(false); }); - return () => { cancelled = true; }; + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; }, [slug]); + const locationLine = profile ? combineLocation(profile) : ""; + const addressLines = profile ? combineAddress(profile) : []; + return ( <>
{/* Cover Image */}
- - + {profile && ( + + )}
- {/* Author Header */}
+ {/* Author Header */}
- {/* Avatar */}
- - + {profile && ( + + )}
- {/* Name + Meta */}

- {author.name} + {loading ? "Loading…" : profile?.display_name || "Author"}

-

{author.title}

+

+ {profile?.role || "Contributor"} +

- {author.location && - + {locationLine && ( + - {author.location} + {locationLine} - } - Joined {author.joinedDate} + )} + {profile?.joined_label && ( + + Joined {profile.joined_label} + + )} {articleTotal.toLocaleString()} articles
{/* Social Links */} -
- {author.social.twitter && - - - - - } - {author.social.linkedin && - - - - - } - {author.social.email && - - - - - } -
+ {profile && (profile.twitter || profile.linkedin || profile.email) && ( +
+ {profile.twitter && ( + + + + )} + {profile.linkedin && ( + + + + )} + {profile.email && ( + + + + )} +
+ )}
{/* Specialties */} - {author.specialties && author.specialties.length > 0 && -
- {author.specialties.map((spec) => - - + {profile?.specialties && profile.specialties.length > 0 && ( +
+ {profile.specialties.map((spec) => ( + {spec} - )} + ))}
- } + )} {/* Tabs */}
- {/* Tab Content */} - {activeTab === "articles" && -
- {articles.length === 0 ? -
+ {/* Articles tab */} + {activeTab === "articles" && ( +
+ {loading ? ( +

Loading articles…

+ ) : articles.length === 0 ? ( +

No articles yet

This author hasn't published any articles yet.

-
: - -
- {articles.map((article) => - - +
+ ) : ( +
+ {articles.map((article) => ( +
- + src={article.image || "/assets/images/article-placeholder.svg"} + alt={article.alt} + width={400} + height={176} + className="w-full h-full object-cover" + />
@@ -490,55 +297,149 @@ export default function AuthorProfilePage() {
- )} + ))}
- } + )}
- } + )} - {activeTab === "about" && -
+ {/* About tab */} + {activeTab === "about" && profile && ( +

Biography

-

{author.bio}

+ {profile.bio ? ( +

+ {profile.bio} +

+ ) : ( +

+ No biography has been added yet for {profile.display_name}. +

+ )} +
-
-
-

Location

-

{author.location}

+ {/* Contact card — render when ANY contact field is populated */} + {(profile.phone || + profile.email || + profile.website || + profile.address_line1 || + profile.city || + profile.country) && ( +
+

Contact

+
+ {addressLines.length > 0 && ( +
+

+ Address +

+
+ {addressLines.map((line, i) => ( + + {line} + {i < addressLines.length - 1 &&
} +
+ ))} +
+
+ )} + {profile.phone && ( +
+

+ Phone +

+ + {profile.phone} + +
+ )} + {profile.email && ( +
+

+ Email +

+ + {profile.email} + +
+ )} + {profile.website && ( + + )}
-
-

Member Since

-

{author.joinedDate}

-
-
-

Articles Published

-

{articleTotal.toLocaleString()}

-
-
-

Contact

- {author.social.email ? - - {author.social.email} - : +
+ )} -

- } + {/* Profile facts */} +
+

Profile

+
+ {locationLine && ( +
+

+ Location +

+

{locationLine}

+
+ )} + {profile.joined_label && ( +
+

+ Member Since +

+

{profile.joined_label}

+
+ )} +
+

+ Articles Published +

+

+ {articleTotal.toLocaleString()} +

+
+
+

+ Role +

+

{profile.role}

- } + )}
{/* In-content 728x90 banner */} {ADS_728X90.length > 0 && ( -
+
)}