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

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 || [],
});
}