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" } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, AuthorProfile> = {
|
||||
"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<string, AuthorArticle[]> = {
|
||||
"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<AuthorProfile | null>(null);
|
||||
const [articles, setArticles] = useState<AuthorArticle[]>([]);
|
||||
const [articleTotal, setArticleTotal] = useState<number>(author.articleCount);
|
||||
const [articlesLoading, setArticlesLoading] = useState(true);
|
||||
const [articleTotal, setArticleTotal] = useState<number>(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 (
|
||||
<>
|
||||
<Header />
|
||||
<main className="bg-[#111111] min-h-screen">
|
||||
{/* Cover Image */}
|
||||
<div className="relative h-40 md:h-52 overflow-hidden">
|
||||
{profile && (
|
||||
<AppImage
|
||||
src={author.coverImage}
|
||||
alt={`${author.name} cover image`}
|
||||
src={profile.cover_url}
|
||||
alt={`${profile.display_name} cover image`}
|
||||
width={1200}
|
||||
height={400}
|
||||
className="w-full h-full object-cover opacity-40" />
|
||||
|
||||
className="w-full h-full object-cover opacity-40"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#111111] via-[#111111]/60 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Author Header */}
|
||||
<div className="max-w-container mx-auto px-4">
|
||||
{/* Author Header */}
|
||||
<div className="relative -mt-16 md:-mt-20 mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-24 h-24 md:w-28 md:h-28 rounded-full overflow-hidden border-4 border-[#111111] flex-shrink-0 bg-[#1a1a1a]">
|
||||
{profile && (
|
||||
<AppImage
|
||||
src={author.avatar}
|
||||
alt={`${author.name} profile photo`}
|
||||
src={profile.avatar_url}
|
||||
alt={`${profile.display_name} profile photo`}
|
||||
width={112}
|
||||
height={112}
|
||||
className="w-full h-full object-cover" />
|
||||
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Name + Meta */}
|
||||
<div className="pb-1 flex-1">
|
||||
<h1 className="font-heading text-2xl md:text-3xl font-bold text-[#e8e8e8] leading-tight">
|
||||
{author.name}
|
||||
{loading ? "Loading…" : profile?.display_name || "Author"}
|
||||
</h1>
|
||||
<p className="font-body text-sm text-[#3b82f6] mt-0.5">{author.title}</p>
|
||||
<p className="font-body text-sm text-[#3b82f6] mt-0.5">
|
||||
{profile?.role || "Contributor"}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2">
|
||||
{author.location &&
|
||||
{locationLine && (
|
||||
<span className="font-body text-xs text-[#666] flex items-center gap-1">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
{author.location}
|
||||
{locationLine}
|
||||
</span>
|
||||
}
|
||||
<span className="font-body text-xs text-[#666]">Joined {author.joinedDate}</span>
|
||||
)}
|
||||
{profile?.joined_label && (
|
||||
<span className="font-body text-xs text-[#666]">
|
||||
Joined {profile.joined_label}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-body text-xs font-bold text-[#888]">
|
||||
{articleTotal.toLocaleString()} articles
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Social Links */}
|
||||
{profile && (profile.twitter || profile.linkedin || profile.email) && (
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
{author.social.twitter &&
|
||||
{profile.twitter && (
|
||||
<a
|
||||
href={author.social.twitter}
|
||||
href={profile.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${author.name} on Twitter`}
|
||||
className="w-8 h-8 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#888] transition-colors rounded-sm">
|
||||
|
||||
aria-label={`${profile.display_name} on Twitter`}
|
||||
className="w-8 h-8 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#888] transition-colors rounded-sm"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
{author.social.linkedin &&
|
||||
)}
|
||||
{profile.linkedin && (
|
||||
<a
|
||||
href={author.social.linkedin}
|
||||
href={profile.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${author.name} on LinkedIn`}
|
||||
className="w-8 h-8 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#888] transition-colors rounded-sm">
|
||||
|
||||
aria-label={`${profile.display_name} on LinkedIn`}
|
||||
className="w-8 h-8 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#888] transition-colors rounded-sm"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
{author.social.email &&
|
||||
)}
|
||||
{profile.email && (
|
||||
<a
|
||||
href={`mailto:${author.social.email}`}
|
||||
aria-label={`Email ${author.name}`}
|
||||
className="w-8 h-8 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#888] transition-colors rounded-sm">
|
||||
|
||||
href={`mailto:${profile.email}`}
|
||||
aria-label={`Email ${profile.display_name}`}
|
||||
className="w-8 h-8 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#888] transition-colors rounded-sm"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||
<polyline points="22,6 12,13 2,6" />
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Specialties */}
|
||||
{author.specialties && author.specialties.length > 0 &&
|
||||
{profile?.specialties && profile.specialties.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{author.specialties.map((spec) =>
|
||||
{profile.specialties.map((spec) => (
|
||||
<span
|
||||
key={spec}
|
||||
className="font-body text-xs text-[#3b82f6] bg-[#1e3a5f]/30 border border-[#1e3a5f] px-2.5 py-1 rounded-sm">
|
||||
|
||||
className="font-body text-xs text-[#3b82f6] bg-[#1e3a5f]/30 border border-[#1e3a5f] px-2.5 py-1 rounded-sm"
|
||||
>
|
||||
{spec}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-0 border-b border-[#2a2a2a] mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab("articles")}
|
||||
className={`font-body text-xs font-bold uppercase tracking-wider px-4 py-3 border-b-2 transition-colors ${
|
||||
activeTab === "articles" ? "border-[#3b82f6] text-[#3b82f6]" : "border-transparent text-[#666] hover:text-[#aaa]"}`
|
||||
}>
|
||||
|
||||
Articles ({articlesLoading ? "…" : articleTotal.toLocaleString()})
|
||||
activeTab === "articles"
|
||||
? "border-[#3b82f6] text-[#3b82f6]"
|
||||
: "border-transparent text-[#666] hover:text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
Articles ({loading ? "…" : articleTotal.toLocaleString()})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("about")}
|
||||
className={`font-body text-xs font-bold uppercase tracking-wider px-4 py-3 border-b-2 transition-colors ${
|
||||
activeTab === "about" ? "border-[#3b82f6] text-[#3b82f6]" : "border-transparent text-[#666] hover:text-[#aaa]"}`
|
||||
}>
|
||||
|
||||
activeTab === "about"
|
||||
? "border-[#3b82f6] text-[#3b82f6]"
|
||||
: "border-transparent text-[#666] hover:text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
About
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === "articles" &&
|
||||
{/* Articles tab */}
|
||||
{activeTab === "articles" && (
|
||||
<div className="pb-10">
|
||||
{articles.length === 0 ?
|
||||
{loading ? (
|
||||
<p className="font-body text-sm text-[#666]">Loading articles…</p>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No articles yet</p>
|
||||
<p className="empty-state-desc">This author hasn't published any articles yet.</p>
|
||||
</div> :
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{articles.map((article) =>
|
||||
{articles.map((article) => (
|
||||
<Link
|
||||
key={article.slug}
|
||||
href={`/articles/${article.slug}`}
|
||||
className="group bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] transition-colors rounded-sm overflow-hidden article-card">
|
||||
|
||||
className="group bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] transition-colors rounded-sm overflow-hidden article-card"
|
||||
>
|
||||
<div className="img-zoom h-44 overflow-hidden">
|
||||
<AppImage
|
||||
src={article.image}
|
||||
src={article.image || "/assets/images/article-placeholder.svg"}
|
||||
alt={article.alt}
|
||||
width={400}
|
||||
height={176}
|
||||
className="w-full h-full object-cover" />
|
||||
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -490,55 +297,149 @@ export default function AuthorProfilePage() {
|
||||
<time className="font-body text-[10px] text-[#555]">{article.date}</time>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
|
||||
{activeTab === "about" &&
|
||||
<div className="max-w-2xl pb-10">
|
||||
{/* About tab */}
|
||||
{activeTab === "about" && profile && (
|
||||
<div className="max-w-3xl pb-10 space-y-5">
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
||||
<p className="section-label mb-4">Biography</p>
|
||||
<p className="font-body text-sm text-[#aaaaaa] leading-relaxed">{author.bio}</p>
|
||||
{profile.bio ? (
|
||||
<p className="font-body text-sm text-[#aaaaaa] leading-relaxed whitespace-pre-line">
|
||||
{profile.bio}
|
||||
</p>
|
||||
) : (
|
||||
<p className="font-body text-sm text-[#666] italic">
|
||||
No biography has been added yet for {profile.display_name}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-[#2a2a2a] grid grid-cols-2 gap-4">
|
||||
{/* Contact card — render when ANY contact field is populated */}
|
||||
{(profile.phone ||
|
||||
profile.email ||
|
||||
profile.website ||
|
||||
profile.address_line1 ||
|
||||
profile.city ||
|
||||
profile.country) && (
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
||||
<p className="section-label mb-4">Contact</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{addressLines.length > 0 && (
|
||||
<div>
|
||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">Location</p>
|
||||
<p className="font-body text-sm text-[#cccccc]">{author.location}</p>
|
||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||
Address
|
||||
</p>
|
||||
<address className="font-body text-sm text-[#cccccc] not-italic leading-relaxed">
|
||||
{addressLines.map((line, i) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < addressLines.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</address>
|
||||
</div>
|
||||
)}
|
||||
{profile.phone && (
|
||||
<div>
|
||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">Member Since</p>
|
||||
<p className="font-body text-sm text-[#cccccc]">{author.joinedDate}</p>
|
||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||
Phone
|
||||
</p>
|
||||
<a
|
||||
href={`tel:${profile.phone.replace(/\s+/g, "")}`}
|
||||
className="font-body text-sm text-[#3b82f6] hover:underline"
|
||||
>
|
||||
{profile.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{profile.email && (
|
||||
<div>
|
||||
<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]">{articleTotal.toLocaleString()}</p>
|
||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||
Email
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${profile.email}`}
|
||||
className="font-body text-sm text-[#3b82f6] hover:underline break-all"
|
||||
>
|
||||
{profile.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{profile.website && (
|
||||
<div>
|
||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">Contact</p>
|
||||
{author.social.email ?
|
||||
<a href={`mailto:${author.social.email}`} className="font-body text-sm text-[#3b82f6] hover:underline">
|
||||
{author.social.email}
|
||||
</a> :
|
||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||
Website
|
||||
</p>
|
||||
<a
|
||||
href={profile.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-body text-sm text-[#3b82f6] hover:underline break-all"
|
||||
>
|
||||
{profile.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="font-body text-sm text-[#cccccc]">—</p>
|
||||
}
|
||||
{/* Profile facts */}
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
||||
<p className="section-label mb-4">Profile</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{locationLine && (
|
||||
<div>
|
||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||
Location
|
||||
</p>
|
||||
<p className="font-body text-sm text-[#cccccc]">{locationLine}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.joined_label && (
|
||||
<div>
|
||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||
Member Since
|
||||
</p>
|
||||
<p className="font-body text-sm text-[#cccccc]">{profile.joined_label}</p>
|
||||
</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-sm text-[#cccccc]">
|
||||
{articleTotal.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||
Role
|
||||
</p>
|
||||
<p className="font-body text-sm text-[#cccccc]">{profile.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* In-content 728x90 banner */}
|
||||
{ADS_728X90.length > 0 && (
|
||||
<div className="max-w-container mx-auto px-4 mt-10 hidden md:flex justify-center" aria-label="Advertisement">
|
||||
<div
|
||||
className="max-w-container mx-auto px-4 mt-10 hidden md:flex justify-center"
|
||||
aria-label="Advertisement"
|
||||
>
|
||||
<AdImage ad={shuffle(ADS_728X90)[0]} page="authors" />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</>);
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
142
supabase/migrations/20260520010000_author_profiles.sql
Normal file
142
supabase/migrations/20260520010000_author_profiles.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
-- ============================================================
|
||||
-- Migration: bb.author_profiles
|
||||
-- Date: 2026-05-20
|
||||
-- Purpose: Per-author bio + contact card backing /authors/[slug].
|
||||
-- Replaces the 540-line static map in src/app/authors/[slug]/page.tsx
|
||||
-- with a DB-driven profile that works for ALL 160 distinct
|
||||
-- author_name values in wp_imported_posts (not just the 7 we
|
||||
-- hand-coded). Join date is computed live from
|
||||
-- MIN(wp_published_at), never stored, so it stays correct
|
||||
-- as more legacy posts get backfilled.
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bb.author_profiles (
|
||||
slug TEXT PRIMARY KEY, -- url slug: "desert-moon-communications"
|
||||
display_name TEXT NOT NULL, -- "Desert Moon Communications"
|
||||
author_names TEXT[] NOT NULL DEFAULT '{}', -- name variants in wp_imported_posts.author_name
|
||||
role TEXT NOT NULL DEFAULT 'Contributor',-- "Contributor" | "Founder of Broadcast Beat" | "Senior Editor" | ...
|
||||
kind TEXT NOT NULL DEFAULT 'contributor',-- 'staff' | 'contributor' | 'pr_firm' | 'person'
|
||||
bio TEXT, -- AI-generated paragraph
|
||||
bio_generated_at TIMESTAMPTZ, -- when Ollama wrote it
|
||||
bio_source_model TEXT, -- 'qwen2.5-coder:32b' etc.
|
||||
|
||||
-- contact card
|
||||
address_line1 TEXT,
|
||||
address_line2 TEXT,
|
||||
city TEXT,
|
||||
state TEXT,
|
||||
postal_code TEXT,
|
||||
country TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
website TEXT,
|
||||
|
||||
-- social
|
||||
twitter TEXT,
|
||||
linkedin TEXT,
|
||||
facebook TEXT,
|
||||
instagram TEXT,
|
||||
|
||||
-- media
|
||||
avatar_url TEXT,
|
||||
cover_url TEXT,
|
||||
|
||||
-- editorial extras
|
||||
specialties TEXT[] DEFAULT '{}',
|
||||
location_text TEXT, -- free-text "Los Angeles, CA" fallback if structured fields absent
|
||||
|
||||
meta JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS author_profiles_kind_idx ON bb.author_profiles(kind);
|
||||
CREATE INDEX IF NOT EXISTS author_profiles_names_gin ON bb.author_profiles USING GIN (author_names);
|
||||
|
||||
-- Trigger to keep updated_at fresh
|
||||
CREATE OR REPLACE FUNCTION bb.author_profiles_touch() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS author_profiles_touch_t ON bb.author_profiles;
|
||||
CREATE TRIGGER author_profiles_touch_t
|
||||
BEFORE UPDATE ON bb.author_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION bb.author_profiles_touch();
|
||||
|
||||
-- Convenience view: join date + post count computed live from imports
|
||||
CREATE OR REPLACE VIEW bb.author_profiles_with_stats AS
|
||||
SELECT
|
||||
p.*,
|
||||
s.first_post_date,
|
||||
s.last_post_date,
|
||||
s.total_articles
|
||||
FROM bb.author_profiles p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
MIN(wp_published_at)::date AS first_post_date,
|
||||
MAX(wp_published_at)::date AS last_post_date,
|
||||
COUNT(*) AS total_articles
|
||||
FROM bb.wp_imported_posts
|
||||
WHERE status = 'published'
|
||||
AND author_name = ANY(p.author_names)
|
||||
) s ON true;
|
||||
|
||||
-- RLS: read-only to anon, full to service_role
|
||||
ALTER TABLE bb.author_profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS author_profiles_public_read ON bb.author_profiles;
|
||||
CREATE POLICY author_profiles_public_read
|
||||
ON bb.author_profiles FOR SELECT
|
||||
TO anon, authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS author_profiles_service_write ON bb.author_profiles;
|
||||
CREATE POLICY author_profiles_service_write
|
||||
ON bb.author_profiles FOR ALL
|
||||
TO service_role
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Seed Ryan immediately so the live site is correct the moment this lands.
|
||||
INSERT INTO bb.author_profiles (
|
||||
slug, display_name, author_names, role, kind, bio,
|
||||
email, website, twitter, linkedin,
|
||||
location_text, country,
|
||||
avatar_url, cover_url,
|
||||
specialties
|
||||
)
|
||||
VALUES (
|
||||
'ryan-salazar',
|
||||
'Ryan Salazar',
|
||||
ARRAY['Ryan Salazar'],
|
||||
'Founder of Broadcast Beat',
|
||||
'staff',
|
||||
'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.',
|
||||
'ryan.salazar@relevantmediaproperties.com',
|
||||
'https://www.relevantmediaproperties.com/',
|
||||
'https://twitter.com/broadcastbeat',
|
||||
'https://linkedin.com/company/broadcastbeat',
|
||||
'South Florida, United States',
|
||||
'United States',
|
||||
'/assets/images/logo.png',
|
||||
'/assets/images/og-image.png',
|
||||
ARRAY['Broadcast Engineering','Live Production','Industry Strategy','Editorial Direction']
|
||||
)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
author_names = EXCLUDED.author_names,
|
||||
role = EXCLUDED.role,
|
||||
kind = EXCLUDED.kind,
|
||||
bio = EXCLUDED.bio,
|
||||
email = EXCLUDED.email,
|
||||
website = EXCLUDED.website,
|
||||
twitter = EXCLUDED.twitter,
|
||||
linkedin = EXCLUDED.linkedin,
|
||||
location_text = EXCLUDED.location_text,
|
||||
country = EXCLUDED.country,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
cover_url = EXCLUDED.cover_url,
|
||||
specialties = EXCLUDED.specialties;
|
||||
Reference in New Issue
Block a user