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:
@@ -144,7 +144,7 @@ function TopThreadsWidget({ categoryId }: { categoryId: string }) {
|
||||
{thread.title}
|
||||
</p>
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -441,7 +441,7 @@ export default function ForumCategoryPage() {
|
||||
</h3>
|
||||
</div>
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +153,7 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
||||
{thread.title}
|
||||
</p>
|
||||
<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 && (
|
||||
<> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></>
|
||||
)}
|
||||
@@ -483,7 +483,7 @@ export default function ForumIndexPage() {
|
||||
{thread.title}
|
||||
</h3>
|
||||
<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 && (
|
||||
<> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></>
|
||||
)}
|
||||
|
||||
@@ -354,7 +354,7 @@ export default function ForumThreadPage() {
|
||||
<Avatar name={thread.author_name} isAI={false} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<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">{timeAgo(thread.created_at)}</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} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 && (
|
||||
<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
|
||||
|
||||
264
src/app/forum/user/[username]/page.tsx
Normal file
264
src/app/forum/user/[username]/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user