initial commit: rocket.new export of broadcastbeat
This commit is contained in:
809
src/app/profile/[userId]/page.tsx
Normal file
809
src/app/profile/[userId]/page.tsx
Normal file
@@ -0,0 +1,809 @@
|
||||
'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 { createClient } from '@/lib/supabase/client';
|
||||
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
full_name: string;
|
||||
avatar_url: string | null;
|
||||
bio: string | null;
|
||||
website: string | null;
|
||||
location: string | null;
|
||||
reputation: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface FollowUser {
|
||||
id: string;
|
||||
full_name: string;
|
||||
avatar_url: string | null;
|
||||
reputation: number;
|
||||
followed_at: string;
|
||||
}
|
||||
|
||||
interface FollowData {
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
isFollowing: boolean;
|
||||
followers: FollowUser[];
|
||||
following: FollowUser[];
|
||||
}
|
||||
|
||||
interface ForumThread {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
vote_score: number;
|
||||
upvotes: number;
|
||||
reply_count: number;
|
||||
view_count: number;
|
||||
created_at: string;
|
||||
forum_categories: { id: string; name: string; slug: string } | null;
|
||||
}
|
||||
|
||||
interface ForumReply {
|
||||
id: string;
|
||||
body: string;
|
||||
vote_score: number;
|
||||
upvotes: number;
|
||||
created_at: string;
|
||||
thread_id: string;
|
||||
forum_threads: { id: string; title: string } | null;
|
||||
}
|
||||
|
||||
interface CategoryContribution {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ProfileStats {
|
||||
totalThreads: number;
|
||||
totalReplies: number;
|
||||
totalPosts: number;
|
||||
totalUpvotesReceived: number;
|
||||
}
|
||||
|
||||
interface UserBadge {
|
||||
badge_type: 'contributor' | 'mentor' | 'expert' | 'moderator';
|
||||
awarded_at: string;
|
||||
awarded_reason: string | null;
|
||||
}
|
||||
|
||||
type ActiveTab = 'overview' | 'threads' | 'replies' | 'followers' | 'following';
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 2) return 'just now';
|
||||
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('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function Avatar({ name, size = 'lg' }: { name: string; size?: 'sm' | 'lg' }) {
|
||||
const initials = name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
const colors = ['#1e3a5f', '#1a3a2a', '#3a1a1a', '#2a1a3a', '#1a2a3a'];
|
||||
const colorIdx = name.charCodeAt(0) % colors.length;
|
||||
const sizeClass = size === 'lg' ? 'w-20 h-20 text-2xl' : 'w-9 h-9 text-xs';
|
||||
return (
|
||||
<div
|
||||
className={`${sizeClass} rounded-full flex items-center justify-center flex-shrink-0 text-white font-bold`}
|
||||
style={{ backgroundColor: colors[colorIdx] }}
|
||||
>
|
||||
{initials || '?'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReputationBadge({ rep }: { rep: number }) {
|
||||
let label = 'Newcomer';
|
||||
let color = 'text-[#888] border-[#333]';
|
||||
if (rep >= 1000) { label = 'Expert'; color = 'text-yellow-400 border-yellow-700'; }
|
||||
else if (rep >= 500) { label = 'Veteran'; color = 'text-purple-400 border-purple-700'; }
|
||||
else if (rep >= 200) { label = 'Contributor'; color = 'text-[#3b82f6] border-[#3b82f6]/50'; }
|
||||
else if (rep >= 50) { label = 'Member'; color = 'text-green-400 border-green-700'; }
|
||||
return (
|
||||
<span className={`font-body text-xs font-bold uppercase tracking-wider px-2 py-0.5 border rounded-sm ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const BADGE_CONFIG: Record<string, { label: string; icon: React.ReactNode; color: string; bg: string; border: string; description: string }> = {
|
||||
contributor: {
|
||||
label: 'Contributor',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'text-[#3b82f6]',
|
||||
bg: 'bg-[#1e3a5f]/40',
|
||||
border: 'border-[#3b82f6]/40',
|
||||
description: 'Active community contributor',
|
||||
},
|
||||
mentor: {
|
||||
label: 'Mentor',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'text-green-400',
|
||||
bg: 'bg-green-900/30',
|
||||
border: 'border-green-700/40',
|
||||
description: 'Helps and guides others',
|
||||
},
|
||||
expert: {
|
||||
label: 'Expert',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'text-yellow-400',
|
||||
bg: 'bg-yellow-900/30',
|
||||
border: 'border-yellow-700/40',
|
||||
description: 'Highly knowledgeable community expert',
|
||||
},
|
||||
moderator: {
|
||||
label: 'Moderator',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-900/30',
|
||||
border: 'border-purple-700/40',
|
||||
description: 'Trusted forum moderator',
|
||||
},
|
||||
};
|
||||
|
||||
function BadgesSection({ badges }: { badges: UserBadge[] }) {
|
||||
if (badges.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{badges.map((badge) => {
|
||||
const cfg = BADGE_CONFIG[badge.badge_type];
|
||||
if (!cfg) return null;
|
||||
return (
|
||||
<div
|
||||
key={badge.badge_type}
|
||||
title={`${cfg.label}: ${cfg.description}${badge.awarded_reason ? ` — ${badge.awarded_reason}` : ''}`}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border text-xs font-body font-bold uppercase tracking-wider ${cfg.color} ${cfg.bg} ${cfg.border}`}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgesCard({ badges }: { badges: UserBadge[] }) {
|
||||
if (badges.length === 0) return null;
|
||||
return (
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-5">
|
||||
<h2 className="font-heading text-sm font-bold text-[#e0e0e0] uppercase tracking-wider mb-4 pb-3 border-b border-[#2a2a2a]">
|
||||
Earned Badges
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{badges.map((badge) => {
|
||||
const cfg = BADGE_CONFIG[badge.badge_type];
|
||||
if (!cfg) return null;
|
||||
return (
|
||||
<div
|
||||
key={badge.badge_type}
|
||||
className={`flex items-start gap-3 p-3 rounded-sm border ${cfg.bg} ${cfg.border}`}
|
||||
>
|
||||
<div className={`mt-0.5 flex-shrink-0 ${cfg.color}`}>{cfg.icon}</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`font-body text-xs font-bold uppercase tracking-wider ${cfg.color}`}>
|
||||
{cfg.label}
|
||||
</div>
|
||||
<div className="font-body text-xs text-[#888] mt-0.5 leading-relaxed">
|
||||
{cfg.description}
|
||||
</div>
|
||||
<div className="font-body text-xs text-[#555] mt-1">
|
||||
Awarded {new Date(badge.awarded_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const params = useParams();
|
||||
const userId = params?.userId as string;
|
||||
const supabase = createClient();
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [threads, setThreads] = useState<ForumThread[]>([]);
|
||||
const [replies, setReplies] = useState<ForumReply[]>([]);
|
||||
const [topThreads, setTopThreads] = useState<ForumThread[]>([]);
|
||||
const [contributionsByCategory, setContributionsByCategory] = useState<CategoryContribution[]>([]);
|
||||
const [stats, setStats] = useState<ProfileStats | null>(null);
|
||||
const [badges, setBadges] = useState<UserBadge[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('overview');
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
|
||||
// Follow state
|
||||
const [followData, setFollowData] = useState<FollowData>({
|
||||
followerCount: 0,
|
||||
followingCount: 0,
|
||||
isFollowing: false,
|
||||
followers: [],
|
||||
following: [],
|
||||
});
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getUser().then(({ data: { user } }) => {
|
||||
setCurrentUserId(user?.id || null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchFollowData = useCallback(async (viewerId: string | null) => {
|
||||
if (!userId) return;
|
||||
const params = new URLSearchParams({ user_id: userId });
|
||||
if (viewerId) params.set('viewer_id', viewerId);
|
||||
const res = await fetch(`/api/forum/follows?${params.toString()}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFollowData(data);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getUser().then(({ data: { user } }) => {
|
||||
const uid = user?.id || null;
|
||||
setCurrentUserId(uid);
|
||||
fetchFollowData(uid);
|
||||
});
|
||||
}, [fetchFollowData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const fetchProfile = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/forum/user-profile?user_id=${userId}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to load profile');
|
||||
setProfile(data.profile);
|
||||
setThreads(data.threads);
|
||||
setReplies(data.replies);
|
||||
setTopThreads(data.topThreads);
|
||||
setContributionsByCategory(data.contributionsByCategory);
|
||||
setStats(data.stats);
|
||||
setBadges(data.badges || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProfile();
|
||||
}, [userId]);
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!currentUserId) return;
|
||||
setFollowLoading(true);
|
||||
try {
|
||||
if (followData.isFollowing) {
|
||||
await fetch(`/api/forum/follows?following_id=${userId}`, { method: 'DELETE' });
|
||||
setFollowData(prev => ({
|
||||
...prev,
|
||||
isFollowing: false,
|
||||
followerCount: Math.max(0, prev.followerCount - 1),
|
||||
followers: prev.followers.filter(f => f.id !== currentUserId),
|
||||
}));
|
||||
} else {
|
||||
await fetch('/api/forum/follows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ following_id: userId }),
|
||||
});
|
||||
setFollowData(prev => ({
|
||||
...prev,
|
||||
isFollowing: true,
|
||||
followerCount: prev.followerCount + 1,
|
||||
}));
|
||||
// Refresh to get updated follower list
|
||||
await fetchFollowData(currentUserId);
|
||||
}
|
||||
} finally {
|
||||
setFollowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const maxCategoryCount = contributionsByCategory[0]?.count || 1;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#111111]">
|
||||
<Header />
|
||||
<div className="max-w-container mx-auto px-4 py-12">
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
<div className="skeleton h-32 rounded-sm" />
|
||||
<div className="skeleton h-64 rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !profile) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#111111]">
|
||||
<Header />
|
||||
<div className="max-w-container mx-auto px-4 py-16 text-center">
|
||||
<p className="font-body text-[#888] text-sm">{error || 'User not found.'}</p>
|
||||
<Link href="/forum" className="mt-4 inline-block font-body text-xs font-bold uppercase tracking-wider text-[#3b82f6] hover:underline">
|
||||
← Back to Forum
|
||||
</Link>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: { id: ActiveTab; label: string; count?: number }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'threads', label: 'Threads', count: stats?.totalThreads },
|
||||
{ id: 'replies', label: 'Replies', count: stats?.totalReplies },
|
||||
{ id: 'followers', label: 'Followers', count: followData.followerCount },
|
||||
{ id: 'following', label: 'Following', count: followData.followingCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#111111]">
|
||||
<Header />
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="bg-[#0d0d0d] border-b border-[#1e1e1e]">
|
||||
<div className="max-w-container mx-auto px-4 py-2.5">
|
||||
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-xs font-body text-[#555]">
|
||||
<Link href="/home-page" className="hover:text-[#3b82f6] transition-colors">Home</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link href="/forum" className="hover:text-[#3b82f6] transition-colors">Forum</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span className="text-[#888]">{profile.full_name || 'User Profile'}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="max-w-container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
||||
{/* Profile Header Card */}
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6 mb-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-5">
|
||||
<Avatar name={profile.full_name || 'User'} size="lg" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-1">
|
||||
<h1 className="font-heading text-2xl font-bold text-[#e8e8e8]">
|
||||
{profile.full_name || 'Anonymous User'}
|
||||
</h1>
|
||||
<ReputationBadge rep={profile.reputation || 0} />
|
||||
{currentUserId && currentUserId !== userId && (
|
||||
<button
|
||||
onClick={handleFollow}
|
||||
disabled={followLoading}
|
||||
className={`font-body text-xs font-bold uppercase tracking-wider px-4 py-1.5 rounded-sm border transition-colors disabled:opacity-60 ${
|
||||
followData.isFollowing
|
||||
? 'bg-[#1e3a5f] text-[#3b82f6] border-[#3b82f6]/50 hover:bg-red-900/30 hover:text-red-400 hover:border-red-700/50'
|
||||
: 'bg-[#3b82f6] text-white border-[#3b82f6] hover:bg-[#2563eb]'
|
||||
}`}
|
||||
>
|
||||
{followLoading ? '...' : followData.isFollowing ? 'Following' : '+ Follow'}
|
||||
</button>
|
||||
)}
|
||||
{currentUserId === userId && (
|
||||
<Link
|
||||
href="/account"
|
||||
className="font-body text-xs font-bold uppercase tracking-wider text-[#888] hover:text-[#3b82f6] border border-[#2a2a2a] hover:border-[#3b82f6] px-3 py-1 rounded-sm transition-colors"
|
||||
>
|
||||
Edit Profile
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{profile.bio && (
|
||||
<p className="font-body text-sm text-[#aaa] mb-2 leading-relaxed">{profile.bio}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs font-body text-[#666]">
|
||||
{profile.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
{profile.location}
|
||||
</span>
|
||||
)}
|
||||
{profile.website && (
|
||||
<a
|
||||
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 hover:text-[#3b82f6] transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
{profile.website.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
Joined {new Date(profile.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Earned Badges inline */}
|
||||
<BadgesSection badges={badges} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reputation & Stats Row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3 mt-6 pt-5 border-t border-[#2a2a2a]">
|
||||
<div className="text-center">
|
||||
<div className="font-heading text-2xl font-bold text-[#3b82f6]">{(profile.reputation || 0).toLocaleString()}</div>
|
||||
<div className="font-body text-xs text-[#666] uppercase tracking-wider mt-0.5">Reputation</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-heading text-2xl font-bold text-[#e0e0e0]">{stats?.totalThreads || 0}</div>
|
||||
<div className="font-body text-xs text-[#666] uppercase tracking-wider mt-0.5">Threads</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-heading text-2xl font-bold text-[#e0e0e0]">{stats?.totalReplies || 0}</div>
|
||||
<div className="font-body text-xs text-[#666] uppercase tracking-wider mt-0.5">Replies</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-heading text-2xl font-bold text-[#e0e0e0]">{stats?.totalUpvotesReceived || 0}</div>
|
||||
<div className="font-body text-xs text-[#666] uppercase tracking-wider mt-0.5">Upvotes</div>
|
||||
</div>
|
||||
<div className="text-center cursor-pointer" onClick={() => setActiveTab('followers')}>
|
||||
<div className="font-heading text-2xl font-bold text-[#e0e0e0] hover:text-[#3b82f6] transition-colors">{followData.followerCount}</div>
|
||||
<div className="font-body text-xs text-[#666] uppercase tracking-wider mt-0.5">Followers</div>
|
||||
</div>
|
||||
<div className="text-center cursor-pointer" onClick={() => setActiveTab('following')}>
|
||||
<div className="font-heading text-2xl font-bold text-[#e0e0e0] hover:text-[#3b82f6] transition-colors">{followData.followingCount}</div>
|
||||
<div className="font-body text-xs text-[#666] uppercase tracking-wider mt-0.5">Following</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-[#2a2a2a] mb-6 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`font-body text-sm font-bold uppercase tracking-wider px-5 py-3 border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-[#3b82f6] text-[#3b82f6]'
|
||||
: 'border-transparent text-[#666] hover:text-[#aaa]'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span className="font-body text-xs bg-[#2a2a2a] text-[#888] px-1.5 py-0.5 rounded-sm">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Earned Badges Card */}
|
||||
{badges.length > 0 && (
|
||||
<div className="lg:col-span-2">
|
||||
<BadgesCard badges={badges} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Threads */}
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-5">
|
||||
<h2 className="font-heading text-sm font-bold text-[#e0e0e0] uppercase tracking-wider mb-4 pb-3 border-b border-[#2a2a2a]">
|
||||
Top Threads
|
||||
</h2>
|
||||
{topThreads.length === 0 ? (
|
||||
<p className="font-body text-sm text-[#555] text-center py-6">No threads yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{topThreads.map((thread) => (
|
||||
<div key={thread.id} className="group">
|
||||
<Link
|
||||
href={`/forum/thread/${thread.id}`}
|
||||
className="font-body text-sm text-[#ccc] hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug"
|
||||
>
|
||||
{thread.title}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs font-body text-[#555]">
|
||||
{thread.forum_categories && (
|
||||
<Link href={`/forum/${thread.forum_categories.slug}`} className="text-[#3b82f6]/70 hover:text-[#3b82f6]">
|
||||
{thread.forum_categories.name}
|
||||
</Link>
|
||||
)}
|
||||
<span className={`font-semibold ${(thread.vote_score || 0) >= 0 ? 'text-[#3b82f6]' : 'text-red-400'}`}>
|
||||
{(thread.vote_score || 0) > 0 ? '+' : ''}{thread.vote_score || 0} pts
|
||||
</span>
|
||||
<span>{thread.reply_count || 0} replies</span>
|
||||
<span>{timeAgo(thread.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contributions by Category */}
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-5">
|
||||
<h2 className="font-heading text-sm font-bold text-[#e0e0e0] uppercase tracking-wider mb-4 pb-3 border-b border-[#2a2a2a]">
|
||||
Contributions by Category
|
||||
</h2>
|
||||
{contributionsByCategory.length === 0 ? (
|
||||
<p className="font-body text-sm text-[#555] text-center py-6">No contributions yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{contributionsByCategory.slice(0, 8).map((cat) => (
|
||||
<div key={cat.slug}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Link
|
||||
href={`/forum/${cat.slug}`}
|
||||
className="font-body text-xs font-semibold text-[#aaa] hover:text-[#3b82f6] transition-colors"
|
||||
>
|
||||
{cat.name}
|
||||
</Link>
|
||||
<span className="font-body text-xs text-[#666]">{cat.count} thread{cat.count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#2a2a2a] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#3b82f6] rounded-full transition-all duration-500"
|
||||
style={{ width: `${(cat.count / maxCategoryCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-5 lg:col-span-2">
|
||||
<h2 className="font-heading text-sm font-bold text-[#e0e0e0] uppercase tracking-wider mb-4 pb-3 border-b border-[#2a2a2a]">
|
||||
Recent Activity
|
||||
</h2>
|
||||
{threads.length === 0 && replies.length === 0 ? (
|
||||
<p className="font-body text-sm text-[#555] text-center py-6">No activity yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
...threads.slice(0, 5).map((t) => ({ type: 'thread' as const, item: t, date: t.created_at })),
|
||||
...replies.slice(0, 5).map((r) => ({ type: 'reply' as const, item: r, date: r.created_at })),
|
||||
]
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.slice(0, 8)
|
||||
.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 py-2 border-b border-[#1e1e1e] last:border-0">
|
||||
<span className={`font-body text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded-sm flex-shrink-0 mt-0.5 ${
|
||||
entry.type === 'thread' ? 'bg-[#1e3a5f] text-[#3b82f6]' : 'bg-[#1a2a1a] text-green-400'
|
||||
}`}>
|
||||
{entry.type === 'thread' ? 'Thread' : 'Reply'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{entry.type === 'thread' ? (
|
||||
<Link
|
||||
href={`/forum/thread/${(entry.item as ForumThread).id}`}
|
||||
className="font-body text-sm text-[#ccc] hover:text-[#3b82f6] transition-colors line-clamp-1"
|
||||
>
|
||||
{(entry.item as ForumThread).title}
|
||||
</Link>
|
||||
) : (
|
||||
<div>
|
||||
<Link
|
||||
href={`/forum/thread/${(entry.item as ForumReply).thread_id}`}
|
||||
className="font-body text-sm text-[#ccc] hover:text-[#3b82f6] transition-colors line-clamp-1"
|
||||
>
|
||||
Re: {(entry.item as ForumReply).forum_threads?.title || 'Thread'}
|
||||
</Link>
|
||||
<p className="font-body text-xs text-[#555] line-clamp-1 mt-0.5">
|
||||
{(entry.item as ForumReply).body}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-body text-xs text-[#555] flex-shrink-0">{timeAgo(entry.date)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Threads Tab */}
|
||||
{activeTab === 'threads' && (
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm">
|
||||
{threads.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="font-body text-sm text-[#555]">No threads posted yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[#2a2a2a]">
|
||||
{threads.map((thread) => (
|
||||
<div key={thread.id} className="p-4 hover:bg-[#1e1e1e] transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/forum/thread/${thread.id}`}
|
||||
className="font-body text-sm font-semibold text-[#ccc] hover:text-[#3b82f6] transition-colors line-clamp-2"
|
||||
>
|
||||
{thread.title}
|
||||
</Link>
|
||||
<p className="font-body text-xs text-[#555] line-clamp-1 mt-1">{thread.body}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs font-body text-[#555]">
|
||||
{thread.forum_categories && (
|
||||
<Link href={`/forum/${thread.forum_categories.slug}`} className="text-[#3b82f6]/70 hover:text-[#3b82f6]">
|
||||
{thread.forum_categories.name}
|
||||
</Link>
|
||||
)}
|
||||
<span>{thread.reply_count || 0} replies</span>
|
||||
<span>{thread.view_count || 0} views</span>
|
||||
<span>{timeAgo(thread.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className={`font-heading text-lg font-bold ${(thread.vote_score || 0) >= 0 ? 'text-[#3b82f6]' : 'text-red-400'}`}>
|
||||
{(thread.vote_score || 0) > 0 ? '+' : ''}{thread.vote_score || 0}
|
||||
</div>
|
||||
<div className="font-body text-xs text-[#555]">score</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Replies Tab */}
|
||||
{activeTab === 'replies' && (
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm">
|
||||
{replies.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="font-body text-sm text-[#555]">No replies posted yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[#2a2a2a]">
|
||||
{replies.map((reply) => (
|
||||
<div key={reply.id} className="p-4 hover:bg-[#1e1e1e] transition-colors">
|
||||
<Link
|
||||
href={`/forum/thread/${reply.thread_id}`}
|
||||
className="font-body text-xs font-bold uppercase tracking-wider text-[#3b82f6]/70 hover:text-[#3b82f6] transition-colors mb-1 block"
|
||||
>
|
||||
Re: {reply.forum_threads?.title || 'Thread'}
|
||||
</Link>
|
||||
<p className="font-body text-sm text-[#aaa] line-clamp-3 leading-relaxed">{reply.body}</p>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs font-body text-[#555]">
|
||||
<span className={`font-semibold ${(reply.vote_score || 0) >= 0 ? 'text-[#3b82f6]' : 'text-red-400'}`}>
|
||||
{(reply.vote_score || 0) > 0 ? '+' : ''}{reply.vote_score || 0} pts
|
||||
</span>
|
||||
<span>{timeAgo(reply.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Followers Tab */}
|
||||
{activeTab === 'followers' && (
|
||||
<FollowUserList
|
||||
users={followData.followers}
|
||||
emptyMessage="No followers yet."
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Following Tab */}
|
||||
{activeTab === 'following' && (
|
||||
<FollowUserList
|
||||
users={followData.following}
|
||||
emptyMessage="Not following anyone yet."
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FollowUserList({
|
||||
users,
|
||||
emptyMessage,
|
||||
currentUserId,
|
||||
}: {
|
||||
users: FollowUser[];
|
||||
emptyMessage: string;
|
||||
currentUserId: string | null;
|
||||
}) {
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-8 text-center">
|
||||
<p className="font-body text-sm text-[#555]">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm divide-y divide-[#2a2a2a]">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-4 p-4 hover:bg-[#1e1e1e] transition-colors">
|
||||
<Avatar name={u.full_name || 'User'} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/profile/${u.id}`}
|
||||
className="font-body text-sm font-semibold text-[#ccc] hover:text-[#3b82f6] transition-colors"
|
||||
>
|
||||
{u.full_name || 'Anonymous User'}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<ReputationBadge rep={u.reputation || 0} />
|
||||
<span className="font-body text-xs text-[#555]">{(u.reputation || 0).toLocaleString()} rep</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className="font-body text-xs text-[#555]">{timeAgo(u.followed_at)}</span>
|
||||
{currentUserId && currentUserId !== u.id && (
|
||||
<Link
|
||||
href={`/profile/${u.id}`}
|
||||
className="font-body text-xs font-bold uppercase tracking-wider text-[#888] hover:text-[#3b82f6] border border-[#2a2a2a] hover:border-[#3b82f6] px-3 py-1 rounded-sm transition-colors"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user