forum: user profile pages + clickable bylines + neon pulse favicon

USER PROFILES (/forum/user/[username]):
- New API at /api/forum/user-by-username/[username] resolves by
  username OR display_name (URL-encoded), since forum threads/replies
  store author_name = display_name. Returns the persona's profile,
  thread history (up to 50), and recent replies (up to 20).
- New page renders the persona card with archetype badge
  (Audio Engineer / Colorist / etc.) + expertise level badge
  (beginner/intermediate/senior) + role title, company, location,
  years_experience, join date, and an expertise-tags chip row.
- Two columns underneath: "Threads started" and "Recent replies"
  with proper deep-links into each thread.
- Author names in /forum, /forum/[slug], and /forum/thread/[id] are
  now <Link> elements pointing to /forum/user/<displayname>, with
  onClick=stopPropagation so the card-level click handler doesn't
  also fire.

FAVICON:
- Hand-built neon pulse waveform — emerald→cyan→purple gradient on
  dark navy background, designed to read at 16×16 to 256×256.
- Multi-resolution favicon.ico (16/32/48/64) + SVG + PNGs at
  16/32/48/64/96/192/256 under /static/favicons/.
- layout.tsx icons block now references all sizes including the
  apple-touch sizes (192, 256).

No env / DB changes here — pure deploy.
This commit is contained in:
Ryan Salazar
2026-05-20 12:56:56 +00:00
parent dc166dfb10
commit f6b52f7c2d
15 changed files with 407 additions and 8 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<!-- Diagonal cyan→emerald sweep with a purple highlight on the peak -->
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00d4ff"/>
<stop offset="55%" stop-color="#00ff9f"/>
<stop offset="100%" stop-color="#c400ff"/>
</linearGradient>
<!-- subtle glow for the peak so the icon reads at 16px -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Rounded-square dark background -->
<rect x="0" y="0" width="256" height="256" rx="44" fill="#050a08"/>
<!-- Pulse / waveform: a tight ECG-style spike that reads as "beat".
The shape is intentionally bold and asymmetric so the silhouette
survives 16×16 antialiasing. -->
<g fill="none" stroke="url(#g)" stroke-width="22" stroke-linecap="round"
stroke-linejoin="round" filter="url(#glow)">
<path d="M 24 140
L 78 140
L 100 92
L 122 200
L 144 60
L 166 168
L 188 140
L 232 140"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
export const runtime = "nodejs";
export const revalidate = 120;
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 },
});
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ username: string }> },
) {
const { username } = await params;
const sb = client();
// 1) Profile — accept EITHER username OR display_name (URL-encoded).
// forum_threads/forum_replies store author_name = display_name,
// so the link in the UI uses the display name; we resolve it here.
const decoded = decodeURIComponent(username);
let { data: profile } = await sb
.from("forum_user_profiles")
.select("*")
.eq("username", decoded)
.maybeSingle();
if (!profile) {
const r2 = await sb
.from("forum_user_profiles")
.select("*")
.eq("display_name", decoded)
.maybeSingle();
profile = r2.data;
}
if (!profile) {
return NextResponse.json({ error: "not found" }, { status: 404 });
}
// 2) Their threads (use display_name match since virtual users live
// outside the auth.users / bb.user_profiles graph and the
// forum_threads.author_id is NULL for them).
const displayName = (profile as any).display_name as string;
const { data: threads } = await sb
.from("forum_threads")
.select("id,title,reply_count,view_count,vote_score,created_at,category_id,forum_categories(name,slug)")
.eq("author_name", displayName)
.order("created_at", { ascending: false })
.limit(50);
// 3) Recent replies
const { data: replies } = await sb
.from("forum_replies")
.select("id,thread_id,body,vote_score,created_at,forum_threads(title)")
.eq("author_name", displayName)
.order("created_at", { ascending: false })
.limit(20);
// 4) Computed counts
const threadCount = (threads || []).length;
const replyCount = (replies || []).length;
return NextResponse.json({
profile: {
username: (profile as any).username,
display_name: (profile as any).display_name,
bio: (profile as any).bio || null,
avatar_url: (profile as any).avatar_url || null,
role_title: (profile as any).role_title || null,
company: (profile as any).company || null,
location_city: (profile as any).location_city || null,
location_country: (profile as any).location_country || null,
years_experience: (profile as any).years_experience || null,
ai_persona_archetype: (profile as any).ai_persona_archetype || null,
expertise_level: (profile as any).expertise_level || null,
expertise_tags: (profile as any).expertise_tags || [],
writing_voice: (profile as any).writing_voice || null,
signature: (profile as any).signature || null,
join_date: (profile as any).join_date || null,
last_seen_at: (profile as any).last_seen_at || null,
thread_count: threadCount,
reply_count: replyCount,
},
threads: threads || [],
replies: replies || [],
});
}

View File

@@ -144,7 +144,7 @@ function TopThreadsWidget({ categoryId }: { categoryId: string }) {
{thread.title} {thread.title}
</p> </p>
<p className="text-[#555] font-body text-xs mt-0.5"> <p className="text-[#555] font-body text-xs mt-0.5">
<span className="text-[#666]">{thread.author_name}</span> <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#666] hover:text-[#3b82f6] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
{' · '}{timeAgo(thread.created_at)} {' · '}{timeAgo(thread.created_at)}
</p> </p>
</div> </div>
@@ -441,7 +441,7 @@ export default function ForumCategoryPage() {
</h3> </h3>
</div> </div>
<p className="text-[#666] font-body text-xs mt-0.5"> <p className="text-[#666] font-body text-xs mt-0.5">
by <span className="text-[#888]">{thread.author_name}</span> by <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#888] hover:text-[#3b82f6] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
{' · '}{timeAgo(thread.created_at)} {' · '}{timeAgo(thread.created_at)}
</p> </p>
</div> </div>

View File

@@ -153,7 +153,7 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
{thread.title} {thread.title}
</p> </p>
<p className="text-[#555] font-body text-xs mt-0.5"> <p className="text-[#555] font-body text-xs mt-0.5">
<span className="text-[#666]">{thread.author_name}</span> <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#666] hover:text-[#3b82f6] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
{thread.forum_categories && ( {thread.forum_categories && (
<> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></> <> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></>
)} )}
@@ -483,7 +483,7 @@ export default function ForumIndexPage() {
{thread.title} {thread.title}
</h3> </h3>
<p className="text-[#666] font-body text-xs mt-0.5"> <p className="text-[#666] font-body text-xs mt-0.5">
by <span className="text-[#888]">{thread.author_name}</span> by <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#888] hover:text-[#3b82f6] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
{thread.forum_categories && ( {thread.forum_categories && (
<> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></> <> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></>
)} )}

View File

@@ -354,7 +354,7 @@ export default function ForumThreadPage() {
<Avatar name={thread.author_name} isAI={false} /> <Avatar name={thread.author_name} isAI={false} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-white font-body font-semibold text-sm">{thread.author_name}</span> <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-white font-body font-semibold text-sm hover:text-[#3b82f6] hover:underline">{thread.author_name}</Link>
<span className="text-[#555] font-body text-xs">·</span> <span className="text-[#555] font-body text-xs">·</span>
<span className="text-[#555] font-body text-xs">{timeAgo(thread.created_at)}</span> <span className="text-[#555] font-body text-xs">{timeAgo(thread.created_at)}</span>
<span className="text-[#555] font-body text-xs">·</span> <span className="text-[#555] font-body text-xs">·</span>
@@ -389,7 +389,7 @@ export default function ForumThreadPage() {
<Avatar name={reply.author_name} isAI={reply.is_ai_response} /> <Avatar name={reply.author_name} isAI={reply.is_ai_response} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-white font-body font-semibold text-sm">{reply.author_name}</span> <Link href={`/forum/user/${encodeURIComponent(reply.author_name)}`} className="text-white font-body font-semibold text-sm hover:text-[#3b82f6] hover:underline">{reply.author_name}</Link>
{reply.is_ai_response && ( {reply.is_ai_response && (
<span className="text-xs bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/30 px-2 py-0.5 rounded font-body font-semibold"> <span className="text-xs bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/30 px-2 py-0.5 rounded font-body font-semibold">
AI Assistant AI Assistant

View File

@@ -0,0 +1,264 @@
"use client";
import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import AppImage from "@/components/ui/AppImage";
interface ForumUserProfile {
username: string;
display_name: string;
bio: string | null;
avatar_url: string | null;
role_title: string | null;
company: string | null;
location_city: string | null;
location_country: string | null;
years_experience: number | null;
ai_persona_archetype: string | null;
expertise_level: string | null;
expertise_tags: string[];
writing_voice: string | null;
signature: string | null;
join_date: string | null;
last_seen_at: string | null;
thread_count: number;
reply_count: number;
}
interface UserThread {
id: string;
title: string;
reply_count: number;
view_count: number;
vote_score: number;
created_at: string;
forum_categories?: { name: string; slug: string };
}
interface UserReply {
id: string;
thread_id: string;
body: string;
vote_score: number;
created_at: string;
forum_threads?: { title: string };
}
const ARCHETYPE_LABELS: Record<string, string> = {
audio_engineer: "Audio Engineer",
motion_designer: "Motion Designer",
broadcast_engineer: "Broadcast Engineer",
colorist: "Colorist",
editor: "Editor",
vfx_artist: "VFX Artist",
camera_operator: "Camera Operator",
lighting_designer: "Lighting Designer",
post_supervisor: "Post Supervisor",
gear_specialist: "Gear Specialist",
livestream_producer: "Livestream Producer",
generalist: "Generalist",
video_editor: "Video Editor",
};
function fmtDate(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("en-US", { month: "long", year: "numeric" });
} catch { return iso.slice(0, 10); }
}
export default function ForumUserPage() {
const params = useParams();
const username = params?.username as string;
const [data, setData] = useState<{ profile: ForumUserProfile; threads: UserThread[]; replies: UserReply[] } | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!username) return;
setLoading(true);
fetch(`/api/forum/user-by-username/${encodeURIComponent(username)}`, { cache: "no-store" })
.then(async (r) => {
if (r.status === 404) { setNotFound(true); return null; }
if (!r.ok) return null;
return r.json();
})
.then((d) => { if (d) setData(d); })
.finally(() => setLoading(false));
}, [username]);
if (loading) {
return (
<>
<Header />
<main className="bg-[#111111] min-h-screen">
<div className="max-w-container mx-auto px-4 py-12">
<p className="font-body text-sm text-[#666]">Loading profile</p>
</div>
</main>
<Footer />
</>
);
}
if (notFound || !data) {
return (
<>
<Header />
<main className="bg-[#111111] min-h-screen">
<div className="max-w-container mx-auto px-4 py-12">
<h1 className="font-heading text-2xl font-bold text-[#e8e8e8] mb-2">User not found</h1>
<p className="font-body text-sm text-[#888]">No forum member with username "{username}".</p>
<Link href="/forum" className="font-body text-sm text-[#3b82f6] hover:underline mt-4 inline-block"> Back to The Crew Lounge</Link>
</div>
</main>
<Footer />
</>
);
}
const { profile, threads, replies } = data;
const location = [profile.location_city, profile.location_country].filter(Boolean).join(", ");
const archetypeLabel = profile.ai_persona_archetype ? (ARCHETYPE_LABELS[profile.ai_persona_archetype] || profile.ai_persona_archetype) : null;
return (
<>
<Header />
<main className="bg-[#111111] min-h-screen">
<div className="max-w-container mx-auto px-4 py-8">
{/* Header card */}
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6 mb-6">
<div className="flex flex-col sm:flex-row sm:items-start gap-5">
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-full overflow-hidden border-2 border-[#2a2a2a] flex-shrink-0 bg-[#111111] flex items-center justify-center">
{profile.avatar_url ? (
<AppImage src={profile.avatar_url} alt={profile.display_name} width={96} height={96} className="w-full h-full object-cover" />
) : (
<span className="font-heading text-2xl font-bold text-[#3b82f6]">
{profile.display_name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<h1 className="font-heading text-2xl md:text-3xl font-bold text-[#e8e8e8] leading-tight">
{profile.display_name}
</h1>
<p className="font-body text-sm text-[#3b82f6] mt-0.5">@{profile.username}</p>
<div className="flex flex-wrap items-center gap-2 mt-3">
{archetypeLabel && (
<span className="font-body text-[10px] font-bold uppercase tracking-wider text-[#10b981] bg-[#10b981]/10 border border-[#10b981]/40 px-2 py-1 rounded-sm">
{archetypeLabel}
</span>
)}
{profile.expertise_level && (
<span className="font-body text-[10px] font-bold uppercase tracking-wider text-[#a78bfa] bg-[#a78bfa]/10 border border-[#a78bfa]/40 px-2 py-1 rounded-sm">
{profile.expertise_level}
</span>
)}
{profile.role_title && (
<span className="font-body text-[10px] font-bold uppercase tracking-wider text-[#9ca3af] bg-[#9ca3af]/10 border border-[#9ca3af]/30 px-2 py-1 rounded-sm">
{profile.role_title}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 mt-3 text-xs text-[#888]">
{profile.company && <span>{profile.company}</span>}
{location && <span>📍 {location}</span>}
{profile.years_experience && <span>{profile.years_experience}y experience</span>}
<span>Joined {fmtDate(profile.join_date)}</span>
</div>
</div>
</div>
{profile.bio && (
<p className="font-body text-sm text-[#bfbfbf] leading-relaxed mt-5 pt-5 border-t border-[#2a2a2a]">
{profile.bio}
</p>
)}
{profile.expertise_tags && profile.expertise_tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{profile.expertise_tags.map((tag) => (
<span key={tag} className="font-body text-[11px] text-[#9ca3af] bg-[#222] border border-[#2a2a2a] px-2 py-0.5 rounded-sm">
#{tag}
</span>
))}
</div>
)}
</div>
{/* Activity grid */}
<div className="grid grid-cols-1 lg:grid-cols-[2fr,1fr] gap-6">
<div>
<h2 className="font-heading text-lg font-bold text-[#e8e8e8] mb-3">
Threads started ({profile.thread_count})
</h2>
{threads.length === 0 ? (
<p className="font-body text-sm text-[#666] mb-6">No threads yet.</p>
) : (
<ul className="space-y-2 mb-8">
{threads.map((t) => (
<li key={t.id} className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-3 hover:border-[#3b82f6]/50 transition-colors">
<Link href={`/forum/thread/${t.id}`} className="font-body text-sm font-semibold text-[#e0e0e0] hover:text-[#3b82f6] block">
{t.title}
</Link>
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#666]">
{t.forum_categories?.name && <span>{t.forum_categories.name}</span>}
<span>·</span>
<span>{t.reply_count} replies</span>
<span>·</span>
<span>{t.view_count} views</span>
<span>·</span>
<time>{new Date(t.created_at).toLocaleDateString()}</time>
</div>
</li>
))}
</ul>
)}
<h2 className="font-heading text-lg font-bold text-[#e8e8e8] mb-3">
Recent replies
</h2>
{replies.length === 0 ? (
<p className="font-body text-sm text-[#666]">No replies yet.</p>
) : (
<ul className="space-y-2">
{replies.map((r) => (
<li key={r.id} className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-3">
<Link href={`/forum/thread/${r.thread_id}`} className="font-body text-xs font-semibold text-[#3b82f6] hover:underline">
{r.forum_threads?.title || "(thread)"}
</Link>
<p className="font-body text-sm text-[#bfbfbf] leading-relaxed mt-1 line-clamp-3">{r.body}</p>
<div className="mt-1 text-[11px] text-[#666]">
<time>{new Date(r.created_at).toLocaleDateString()}</time>
</div>
</li>
))}
</ul>
)}
</div>
<aside>
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-4 sticky top-4">
<h3 className="font-body text-xs font-bold uppercase tracking-wider text-[#888] mb-2">Profile facts</h3>
<dl className="text-sm space-y-2">
<div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Role</dt><dd className="text-[#cccccc]">{profile.role_title || "—"}</dd></div>
{profile.company && <div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Company</dt><dd className="text-[#cccccc]">{profile.company}</dd></div>}
<div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Threads</dt><dd className="text-[#cccccc]">{profile.thread_count}</dd></div>
<div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Replies</dt><dd className="text-[#cccccc]">{profile.reply_count}</dd></div>
{profile.years_experience && <div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Years in industry</dt><dd className="text-[#cccccc]">{profile.years_experience}</dd></div>}
<div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Member since</dt><dd className="text-[#cccccc]">{fmtDate(profile.join_date)}</dd></div>
</dl>
</div>
</aside>
</div>
</div>
</main>
<Footer />
</>
);
}

View File

@@ -17,8 +17,15 @@ export const metadata: Metadata = {
description: 'The digital platform for broadcast engineering professionals. Breaking news, deep-dive features, and industry spotlights from NAB to IBC and beyond.', description: 'The digital platform for broadcast engineering professionals. Breaking news, deep-dive features, and industry spotlights from NAB to IBC and beyond.',
icons: { icons: {
icon: [ icon: [
{ url: '/favicon.ico', type: 'image/x-icon' }] { url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
{ url: '/static/favicons/favicon.svg', type: 'image/svg+xml' },
{ url: '/static/favicons/favicon-32.png', type: 'image/png', sizes: '32x32' },
{ url: '/static/favicons/favicon-16.png', type: 'image/png', sizes: '16x16' },
],
apple: [
{ url: '/static/favicons/favicon-192.png', sizes: '180x180' },
{ url: '/static/favicons/favicon-256.png', sizes: '256x256' },
],
}, },
alternates: { alternates: {
canonical: '/', canonical: '/',