Files
avbeat-com/src/app/forum/[slug]/page.tsx
Local Administrator f44add70c7 feat(brand+promotion): brand-gradient system, restyled /about/* + team + contact, alias-based client matcher
Three coupled rollouts in one shot.

PART A — client highlight matcher (DB migration applied separately):
- promoted-articles.ts: AdvertiserName now carries brand_aliases; matcher
  iterates legal name + every alias with word-boundary regex and picks the
  longest match across all candidates so "Magewell Launches…" resolves to
  "Magewell Electronics Co., Ltd." via its Magewell alias.
- New isStoryAboutClient gate: hard-accept at position 0, accept if any
  editorial frame ("Product Spotlight:", "Review:", "First Look", …) appears
  in the prefix, hard-reject if the prefix ends with a partner/usage phrase
  ("with", "using", "powered by", "dealer of", "integration with", "used",
  "deploys", "runs on", "leverages", …), otherwise accept within a 32-char
  subject window. Validated against 5,000 historical posts: 27 partner
  false-positives blocked, 3 editorial-frame stories correctly saved, the
  Magewell article now highlights.

PART B — restyling /about, /about/team, /about/contact:
- New .brand-gradient utility: #2563EB→#1E3A8A linear-gradient with a
  16%-opacity diagonal sheen that sweeps in the first 30% of a 7s cycle
  and rests offscreen-right for the rest. Respects prefers-reduced-motion
  (band parked statically at 6% opacity).
- Team page: dark legacy hero (red radial, #ff5a55 eyebrows) replaced with
  brand-gradient hero; outer text wrapper #e5e7eb → #0F172A so section
  headings stop being white-on-white; all serif H1/H2/H3 → font-display;
  red accents (#ff5a55 / #ff6b66 / rgba(214,7,1)) → blue (#93C5FD / #7DD3FC
  / rgba(29,78,216)).
- About page: H1 + Stat values → font-display; Stat cards get a brand-
  gradient top stripe.
- Contact page: outer wrapper #e5e7eb → #0F172A (fixes the white-on-white
  Contact H1); H1 gets explicit text-[#0F172A] + font-display; dark contact
  cards keep dark bg but gain a brand-gradient top stripe + explicit
  text-[#e5e7eb] body + brighter cyan eyebrow/link colors.

PART C — richer Industry News client highlight:
- ArticleFeed: client-promoted row swaps the static border-l-4 for an
  absolute-positioned .brand-gradient strip on the left edge (sheen pulses
  through). Row gets a slightly richer bg tint (from-#1D4ED8/12 via /4).
- HIGHLIGHT pill now uses .brand-gradient instead of the static blue chip.

Also shipping the earlier-staged work:
- AnimatedLogo onDark: white ring around tile + brighter (#E2E8F0) tagline
  + cyan accent rule for the blue header.
- Article-prose: em/strong/code dark colors (em fixes the trailing
  copyright/trademark ghost text), hr line color, figcaption readable.
- ArticleComments: "Sign in to join the discussion" bar uses brand-gradient
  + white/outline buttons that read on blue.
- Forum: Discover/Following, Newest/Top Voted/Unanswered, Sign in to post,
  Start a thread, Create account — slim pills, brand-gradient active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 18:37:15 +00:00

494 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import ImageAttachButton from '@/components/forum/ImageAttachButton';
import { createClient } from '@/lib/supabase/client';
interface ForumCategory {
id: string;
slug: string;
name: string;
description: string;
icon: string;
thread_count: number;
}
interface ForumThread {
id: string;
title: string;
body: string;
author_name: string;
status: string;
reply_count: number;
view_count: number;
upvotes?: number;
last_reply_at: string;
created_at: string;
is_ai_seeded: boolean;
}
type SortOption = 'newest' | 'top-voted' | 'unanswered';
type TopMetric = 'upvotes' | 'replies' | 'views';
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}
function TopThreadsWidget({ categoryId }: { categoryId: string }) {
const [activeMetric, setActiveMetric] = useState<TopMetric>('upvotes');
const [threads, setThreads] = useState<ForumThread[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const sortMap: Record<TopMetric, string> = {
upvotes: 'top-voted',
replies: 'most-replied',
views: 'most-viewed',
};
const params = new URLSearchParams({ category_id: categoryId, limit: '5', sort: sortMap[activeMetric] });
fetch(`/api/forum/threads?${params.toString()}`)
.then(r => r.json())
.then(d => {
setThreads(d.threads || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, [activeMetric, categoryId]);
const metrics: { value: TopMetric; label: string; icon: string }[] = [
{ value: 'upvotes', label: 'Top Voted', icon: '▲' },
{ value: 'replies', label: 'Most Replies', icon: '💬' },
{ value: 'views', label: 'Most Viewed', icon: '👁' },
];
const getMetricValue = (thread: ForumThread) => {
if (activeMetric === 'upvotes') return { value: thread.upvotes ?? 0, label: 'votes', color: 'text-[#22c55e]' };
if (activeMetric === 'replies') return { value: thread.reply_count, label: 'replies', color: 'text-[#1D4ED8]' };
return { value: thread.view_count, label: 'views', color: 'text-[#f59e0b]' };
};
return (
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-xl overflow-hidden mb-6">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[#DCE6F2]">
<div className="flex items-center gap-2">
<span className="text-lg">🏆</span>
<h2 className="text-[#0F172A] font-heading font-bold text-base">Top Performing Threads</h2>
</div>
{/* Metric Tabs */}
<div className="flex gap-1 bg-[#111] border border-[#DCE6F2] rounded-lg p-1">
{metrics.map(m => (
<button
key={m.value}
onClick={() => setActiveMetric(m.value)}
className={`px-3 py-1 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
activeMetric === m.value
? 'bg-[#1D4ED8] text-white'
: 'text-[#666] hover:text-[#aaa]'
}`}
>
<span className="mr-1">{m.icon}</span>
{m.label}
</button>
))}
</div>
</div>
{/* Thread List */}
<div className="divide-y divide-[#1e1e1e]">
{loading && (
<>
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-5 py-3">
<div className="w-6 h-6 bg-[#DCE6F2] rounded animate-pulse flex-shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="h-3.5 bg-[#DCE6F2] rounded animate-pulse w-3/4" />
<div className="h-2.5 bg-[#1e1e1e] rounded animate-pulse w-1/3" />
</div>
<div className="w-10 h-8 bg-[#DCE6F2] rounded animate-pulse flex-shrink-0" />
</div>
))}
</>
)}
{!loading && threads.length === 0 && (
<div className="px-5 py-8 text-center text-[#555] font-body text-sm">
No threads yet. Be the first to start a discussion!
</div>
)}
{!loading && threads.map((thread, idx) => {
const metric = getMetricValue(thread);
return (
<Link
key={thread.id}
href={`/forum/thread/${thread.id}`}
className="flex items-center gap-3 px-5 py-3 hover:bg-[#e2e8f0] transition-colors group"
>
{/* Rank */}
<span className={`flex-shrink-0 w-6 text-center font-heading font-bold text-sm ${
idx === 0 ? 'text-[#f59e0b]' : idx === 1 ? 'text-[#9ca3af]' : idx === 2 ? 'text-[#b45309]' : 'text-[#444]'
}`}>
{idx + 1}
</span>
{/* Thread info */}
<div className="flex-1 min-w-0">
<p className="text-[#0F172A] font-body text-sm font-semibold group-hover:text-[#1D4ED8] transition-colors truncate leading-snug">
{thread.title}
</p>
<p className="text-[#555] font-body text-xs mt-0.5">
<Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#666] hover:text-[#1D4ED8] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
{' · '}{timeAgo(thread.created_at)}
</p>
</div>
{/* Metric value */}
<div className="flex-shrink-0 text-right min-w-[48px]">
<div className={`font-heading font-bold text-sm ${metric.color}`}>{metric.value.toLocaleString()}</div>
<div className="text-[#444] font-body text-xs">{metric.label}</div>
</div>
</Link>
);
})}
</div>
</div>
);
}
export default function ForumCategoryPage() {
const params = useParams();
const slug = params?.slug as string;
const [category, setCategory] = useState<ForumCategory | null>(null);
const [threads, setThreads] = useState<ForumThread[]>([]);
const [loading, setLoading] = useState(true);
const [threadsLoading, setThreadsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showNewThread, setShowNewThread] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newBody, setNewBody] = useState('');
const [submitting, setSubmitting] = useState(false);
const [newError, setNewError] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
const supabase = createClient();
supabase.auth.getUser().then(({ data: { user } }) => setIsAuthenticated(!!user));
}, []);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [sort, setSort] = useState<SortOption>('newest');
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 350);
return () => clearTimeout(t);
}, [search]);
// Load category info once
useEffect(() => {
if (!slug) return;
fetch('/api/forum/categories')
.then(r => r.json())
.then(d => {
const cat = (d.categories || []).find((c: ForumCategory) => c.slug === slug);
setCategory(cat || null);
setLoading(false);
})
.catch(() => {
setError('Failed to load category.');
setLoading(false);
});
}, [slug]);
// Load threads when category/search/sort changes
const fetchThreads = useCallback(() => {
if (!category) return;
setThreadsLoading(true);
const qParams = new URLSearchParams({ category_id: category.id, limit: '50', sort });
if (debouncedSearch.trim()) qParams.set('search', debouncedSearch.trim());
fetch(`/api/forum/threads?${qParams.toString()}`)
.then(r => r.json())
.then(d => {
setThreads(d?.threads || []);
setThreadsLoading(false);
})
.catch(() => {
setError('Failed to load threads.');
setThreadsLoading(false);
});
}, [category, debouncedSearch, sort]);
useEffect(() => {
fetchThreads();
}, [fetchThreads]);
const handleNewThread = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim() || !newBody.trim() || !category) return;
setSubmitting(true);
setNewError(null);
try {
const res = await fetch('/api/forum/threads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
category_id: category.id,
title: newTitle,
body: newBody,
}),
});
const data = await res.json();
if (!res.ok) {
if (res.status === 401) {
window.location.href = `/forum/login?next=${encodeURIComponent(window.location.pathname)}`;
return;
}
throw new Error(data.error || `Failed (${res.status})`);
}
if (data.thread) {
setThreads(prev => [data.thread, ...prev]);
setNewTitle('');
setNewBody('');
setShowNewThread(false);
}
} catch (err: any) {
setNewError(err.message || 'Failed to create thread. Please try again.');
} finally {
setSubmitting(false);
}
};
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'newest', label: 'Newest' },
{ value: 'top-voted', label: 'Top Voted' },
{ value: 'unanswered', label: 'Unanswered' },
];
return (
<>
<Header />
<main className="min-h-screen bg-[#F8FAFC]">
{/* Breadcrumb + Header */}
<div className="bg-[#FFFFFF] border-b border-[#2a3a50]">
<div className="max-w-container mx-auto px-4 py-8">
<nav className="text-sm font-body text-[#666] mb-3">
<Link href="/forum" className="hover:text-[#1D4ED8] transition-colors">Forum</Link>
<span className="mx-2"></span>
<span className="text-[#0F172A]">{category?.name || slug}</span>
</nav>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-2xl">{category?.icon || '💬'}</span>
<div>
<h1 className="text-2xl font-heading font-bold text-[#0F172A]">{category?.name || 'Loading...'}</h1>
{category && (
<p className="text-[#475569] font-body text-sm mt-0.5">{category.thread_count} threads</p>
)}
</div>
</div>
{isAuthenticated ? (
<button
onClick={() => setShowNewThread(!showNewThread)}
className="brand-gradient flex-shrink-0 font-body font-semibold text-sm px-4 py-2 rounded-full"
>
+ New Thread
</button>
) : (
<Link
href={`/forum/login?next=${typeof window === 'undefined' ? '/forum' : encodeURIComponent(window.location.pathname)}`}
className="brand-gradient flex-shrink-0 font-body font-semibold text-sm px-4 py-2 rounded-full"
>
Sign in to post
</Link>
)}
</div>
</div>
</div>
<div className="max-w-container mx-auto px-4 py-6">
{/* New Thread Form — auth-gated */}
{showNewThread && isAuthenticated && (
<form onSubmit={handleNewThread} className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-5 mb-6">
<h3 className="text-[#0F172A] font-heading font-semibold mb-4">Start a New Thread</h3>
<div className="space-y-3">
<input
type="text"
placeholder="Thread title *"
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
required
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm placeholder-[#888] focus:outline-none focus:border-[#1D4ED8]"
/>
<textarea
placeholder="What would you like to discuss? *"
value={newBody}
onChange={e => setNewBody(e.target.value)}
required
rows={5}
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm placeholder-[#888] focus:outline-none focus:border-[#1D4ED8] resize-none"
/>
<div className="flex items-center gap-3">
<ImageAttachButton onInsert={(md) => setNewBody((b) => (b || "") + md)} />
<span className="text-[10px] text-[#666]">Auto-optimized, never &gt; 20 MB</span>
</div>
{newError && (
<div className="bg-[#2a0a0a] border border-[#1D4ED8] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{newError}
</div>
)}
<div className="flex gap-3">
<button
type="submit"
disabled={submitting}
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
>
{submitting ? 'Posting...' : 'Post Thread'}
</button>
<button
type="button"
onClick={() => setShowNewThread(false)}
className="bg-[#DCE6F2] hover:bg-[#cbd5e1] text-[#0F172A] font-body text-sm px-5 py-2 rounded-lg transition-colors"
>
Cancel
</button>
</div>
</div>
</form>
)}
{/* Search + Sort Bar */}
<div className="flex flex-col sm:flex-row gap-3 mb-5">
{/* Search */}
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#555]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="text"
placeholder="Search threads in this category..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#FFFFFF] border border-[#DCE6F2] focus:border-[#1D4ED8] rounded-lg pl-9 pr-4 py-2.5 text-[#0F172A] font-body text-sm placeholder-[#888] focus:outline-none transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#555] hover:text-[#aaa] transition-colors"
aria-label="Clear search"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Sort Options — segmented pill control; active uses brand-gradient */}
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-full p-1 flex-shrink-0">
{sortOptions.map(opt => (
<button
key={opt.value}
onClick={() => setSort(opt.value)}
className={`px-3 py-1 rounded-full text-xs font-body font-semibold transition-colors whitespace-nowrap ${
sort === opt.value
? 'brand-gradient'
: 'text-[#475569] hover:text-[#0F172A]'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{(loading || threadsLoading) && (
<div className="space-y-2">
{[...Array(10)].map((_, i) => (
<div key={i} className="bg-[#FFFFFF] rounded-lg h-16 animate-pulse border border-[#DCE6F2]" />
))}
</div>
)}
{error && (
<div className="bg-red-900/20 border border-red-800 rounded-lg p-4 text-red-400 font-body">
{error}
</div>
)}
{!loading && !threadsLoading && !error && threads.length === 0 && (
<div className="text-center py-16 text-[#475569] font-body">
<p className="text-lg mb-2 text-[#0F172A] font-semibold">
{search ? 'No threads match your search' : sort === 'unanswered' ? 'No unanswered threads' : 'No threads yet'}
</p>
<p className="text-sm">
{search ? 'Try different keywords.' : sort === 'unanswered' ? 'All threads have replies — great community!' : 'Be the first to start a discussion in this category.'}
</p>
</div>
)}
{!loading && !threadsLoading && threads.length > 0 && (
<div className="space-y-1">
{debouncedSearch && (
<p className="text-[#666] font-body text-xs mb-2">{threads.length} result{threads.length !== 1 ? 's' : ''} for &ldquo;{debouncedSearch}&rdquo;</p>
)}
{threads.map(thread => (
<Link
key={thread.id}
href={`/forum/thread/${thread.id}`}
className="flex items-center gap-4 bg-[#FFFFFF] hover:bg-[#e2e8f0] border border-[#DCE6F2] hover:border-[#1D4ED8] rounded-lg px-4 py-3 transition-all group"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{thread.status === 'pinned' && (
<span className="text-xs bg-[#1D4ED8]/20 text-[#1D4ED8] px-2 py-0.5 rounded font-body font-semibold">PINNED</span>
)}
{thread.status === 'closed' && (
<span className="text-xs bg-[#555]/20 text-[#888] px-2 py-0.5 rounded font-body">CLOSED</span>
)}
{thread.reply_count === 0 && (
<span className="text-xs bg-[#f59e0b]/10 text-[#f59e0b] px-2 py-0.5 rounded font-body">UNANSWERED</span>
)}
<h3 className="text-[#0F172A] font-body font-semibold text-sm group-hover:text-[#1D4ED8] transition-colors truncate">
{thread.title}
</h3>
</div>
<p className="text-[#666] font-body text-xs mt-0.5">
by <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#888] hover:text-[#1D4ED8] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
{' · '}{timeAgo(thread.created_at)}
</p>
</div>
<div className="flex-shrink-0 text-right hidden sm:block">
<div className="text-[#0F172A] font-body text-sm font-semibold">{thread.reply_count}</div>
<div className="text-[#475569] font-body text-xs">replies</div>
</div>
{sort === 'top-voted' && (
<div className="flex-shrink-0 text-right hidden md:block">
<div className="text-[#22c55e] font-body text-sm font-semibold">+{thread.upvotes ?? 0}</div>
<div className="text-[#475569] font-body text-xs">votes</div>
</div>
)}
<div className="flex-shrink-0 text-right hidden md:block">
<div className="text-[#666] font-body text-xs">{timeAgo(thread.last_reply_at)}</div>
<div className="text-[#444] font-body text-xs">last reply</div>
</div>
</Link>
))}
</div>
)}
</div>
</main>
<Footer />
</>
);
}