Modernize /about/team layout + add 9 SD-generated persona portraits + dot-form email helper
Automated persistence of 2026-05-30 session changes. See infrastructure_audit/ for context.
BIN
public/assets/images/personas/chloe-delgado.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/assets/images/personas/darren-okafor.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/assets/images/personas/elena-vasquez.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/assets/images/personas/jeff-victor.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/assets/images/personas/marcus-halverson.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/assets/images/personas/mike-trayton.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/assets/images/personas/priya-rao.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/assets/images/personas/riley-park.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/assets/images/personas/sarah-quinn.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
@@ -27,6 +27,7 @@ interface OpsPersona {
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
extension: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export default async function TeamPage() {
|
||||
@@ -60,9 +61,9 @@ export default async function TeamPage() {
|
||||
);
|
||||
const { data: opsData } = await sbOps
|
||||
.from("personas")
|
||||
.select("id, display_name, role, email, phone, extension")
|
||||
.select("id, display_name, role, email, phone, extension, avatar_url")
|
||||
.eq("active", true)
|
||||
.in("id", ["chloe", "riley"]);
|
||||
.in("id", ["chloe", "jeff", "riley"]);
|
||||
const ops = (opsData || []) as OpsPersona[];
|
||||
|
||||
const displayBeat = (p: Persona) =>
|
||||
@@ -80,7 +81,7 @@ export default async function TeamPage() {
|
||||
// are set up centrally; this is the authoring address surfaced to readers.
|
||||
const editorialEmail = (p: Persona): { user: string; domain: string } | null => {
|
||||
if (!p.slug) return null;
|
||||
return { user: p.slug, domain: "broadcastbeat.com" };
|
||||
return { user: p.slug.replace(/-/g, "."), domain: "broadcastbeat.com" };
|
||||
};
|
||||
|
||||
const splitEmail = (email: string): { user: string; domain: string } | null => {
|
||||
@@ -95,82 +96,152 @@ export default async function TeamPage() {
|
||||
<div className="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
|
||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
||||
<main>
|
||||
<header className="mb-8 border-b border-[#252525] pb-6">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
||||
Broadcast Beat
|
||||
{/* Hero */}
|
||||
<header className="relative overflow-hidden rounded-xl mb-10 border border-[#1a1f2a] bg-gradient-to-br from-[#0d1118] via-[#0b0f17] to-[#0d1118]">
|
||||
<div className="absolute inset-0 opacity-[0.04] bg-[radial-gradient(ellipse_at_top_right,#d60701,transparent_60%)]" aria-hidden />
|
||||
<div className="relative px-6 py-10 sm:px-10 sm:py-14">
|
||||
<div className="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.22em] text-[#ff5a55] mb-3">
|
||||
<span className="w-6 h-px bg-[#d60701]" />
|
||||
Broadcast Beat · editorial
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
|
||||
The team behind every byline
|
||||
</h1>
|
||||
<p className="mt-4 text-[#9ca3af] text-base md:text-lg max-w-2xl leading-relaxed">
|
||||
Beats covered by trained AI personas working under house style, plus the
|
||||
business and support people who keep things moving.
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Editorial team</h1>
|
||||
<p className="mt-3 text-[#9ca3af] text-base">
|
||||
Staff journalists by beat — a mix of human reporters and trained AI personas operating under house style.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{team.map((p) => (
|
||||
<li key={p.slug} className="rounded border border-[#252525] bg-[#0b0f17] p-5 flex gap-4 hover:border-[var(--color-text-info,#60a5fa)]/60 transition-colors">
|
||||
{p.avatar_url && (
|
||||
<img
|
||||
src={p.avatar_url}
|
||||
alt={p.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full border border-[#252525] flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-serif text-lg font-semibold">{p.name}</h2>
|
||||
{(p.beat || p.slug === "ryan-salazar") && (
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)] mt-0.5">
|
||||
{displayBeat(p)}
|
||||
</div>
|
||||
)}
|
||||
{p.bio && <p className="text-sm text-[#9ca3af] mt-2 leading-relaxed">{p.bio}</p>}
|
||||
{(() => {
|
||||
const e = editorialEmail(p);
|
||||
if (!e) return null;
|
||||
return (
|
||||
<p className="text-xs text-[#9ca3af] mt-2">
|
||||
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#60a5fa]" />
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Editorial team — three-col grid on desktop, larger portraits */}
|
||||
<section className="mb-14">
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">Editorial</h2>
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
|
||||
{team.length} {team.length === 1 ? "writer" : "writers"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ops.length > 0 && (
|
||||
<>
|
||||
<header className="mt-12 mb-6 border-b border-[#252525] pb-4">
|
||||
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">Accounts & support</h2>
|
||||
<p className="mt-2 text-[#9ca3af] text-sm">
|
||||
For billing, accounts receivable, vendor onboarding, and general support questions — these are the people to reach.
|
||||
</p>
|
||||
</header>
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{ops.map((p) => (
|
||||
<li key={p.id} className="rounded border border-[#252525] bg-[#0b0f17] p-5 flex gap-4 hover:border-[var(--color-text-info,#60a5fa)]/60 transition-colors">
|
||||
<div className="w-16 h-16 rounded-full border border-[#252525] flex-shrink-0 bg-[#1a1a1a] flex items-center justify-center font-serif font-bold text-xl text-[#60a5fa]">
|
||||
{initials(p.display_name)}
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
{team.map((p) => {
|
||||
const e = editorialEmail(p);
|
||||
return (
|
||||
<li
|
||||
key={p.slug}
|
||||
className="group relative rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#d60701]/50 hover:shadow-[0_0_0_1px_rgba(214,7,1,0.15)] transition-all"
|
||||
>
|
||||
{/* Top accent */}
|
||||
<div className="absolute top-0 left-5 right-5 h-px bg-gradient-to-r from-transparent via-[#d60701]/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden />
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
{p.avatar_url ? (
|
||||
<a href={`/authors/${p.slug}`} className="flex-shrink-0">
|
||||
<img
|
||||
src={p.avatar_url}
|
||||
alt={p.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] group-hover:border-[#d60701]/40 transition-colors object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] bg-[#141a25] flex items-center justify-center font-serif font-bold text-2xl text-[#6c7686] flex-shrink-0">
|
||||
{initials(p.name)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<a href={`/authors/${p.slug}`} className="block hover:underline">
|
||||
<h3 className="font-serif text-xl font-bold leading-tight">{p.name}</h3>
|
||||
</a>
|
||||
{(p.beat || p.slug === "ryan-salazar") && (
|
||||
<div className="inline-block mt-1.5 px-2 py-0.5 rounded-sm bg-[#d60701]/10 border border-[#d60701]/25 text-[10px] font-mono uppercase tracking-[0.12em] text-[#ff6b66]">
|
||||
{displayBeat(p)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.bio && (
|
||||
<p className="text-sm text-[#aab1bd] mt-4 leading-relaxed line-clamp-4">
|
||||
{p.bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{e && (
|
||||
<div className="mt-4 pt-4 border-t border-[#1a1f2a] flex items-center justify-between text-xs">
|
||||
<ObfuscatedEmail
|
||||
user={e.user}
|
||||
domain={e.domain}
|
||||
className="text-[#aab1bd] hover:text-[#ff6b66] font-mono"
|
||||
/>
|
||||
<a
|
||||
href={`/authors/${p.slug}`}
|
||||
className="text-[10px] font-mono uppercase tracking-[0.12em] text-[#6c7686] hover:text-[#ff6b66]"
|
||||
>
|
||||
View →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Operations — denser layout */}
|
||||
{ops.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">
|
||||
Accounts & support
|
||||
</h2>
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
|
||||
{ops.length} {ops.length === 1 ? "contact" : "contacts"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[#9ca3af] text-sm mb-5 max-w-2xl">
|
||||
For billing, accounts receivable, vendor onboarding, sales, and
|
||||
general support questions — these are the people to reach.
|
||||
</p>
|
||||
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{ops.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 flex gap-4 hover:border-[#d60701]/40 transition-colors"
|
||||
>
|
||||
{p.avatar_url ? (
|
||||
<img
|
||||
src={p.avatar_url}
|
||||
alt={p.display_name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 bg-[#141a25] flex items-center justify-center font-serif font-bold text-xl text-[#6c7686]">
|
||||
{initials(p.display_name)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-serif text-lg font-semibold">{p.display_name}</h3>
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)] mt-0.5">
|
||||
<h3 className="font-serif text-lg font-bold leading-tight">{p.display_name}</h3>
|
||||
<div className="inline-block mt-1 px-2 py-0.5 rounded-sm bg-[#1a2535] border border-[#3b82f6]/30 text-[10px] font-mono uppercase tracking-[0.12em] text-[#7ba0ff]">
|
||||
{p.role}
|
||||
</div>
|
||||
<div className="text-xs text-[#9ca3af] mt-2 space-y-0.5">
|
||||
<div className="text-xs text-[#aab1bd] mt-3 space-y-1">
|
||||
{p.email && (() => {
|
||||
const e = splitEmail(p.email);
|
||||
if (!e) return null;
|
||||
return (
|
||||
<div>
|
||||
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#60a5fa]" />
|
||||
<div className="font-mono">
|
||||
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#ff6b66]" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{p.phone && (
|
||||
<div>
|
||||
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#60a5fa]">
|
||||
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#ff6b66] font-mono">
|
||||
{p.phone}{p.extension ? ` ext. ${p.extension}` : ''}
|
||||
</a>
|
||||
</div>
|
||||
@@ -180,7 +251,7 @@ export default async function TeamPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
||||