feat(forum): implement real forum with Supabase integration

- Forum.tsx: categories, discussions, stats, search, sort, announcements
- ForumTopic.tsx: replies, voting, bookmarks, report, nested comments
- App.tsx: added ForumTopic route for /forum/:id
This commit is contained in:
2025-12-11 05:20:45 +03:00
parent 30663941ff
commit 0cddd9c3f1
294 changed files with 1695 additions and 193 deletions
+2
View File
@@ -50,6 +50,7 @@ const Developers = lazy(() => import('@/pages/Developers'));
const Grants = lazy(() => import('@/pages/Grants'));
const Wiki = lazy(() => import('@/pages/Wiki'));
const Forum = lazy(() => import('@/pages/Forum'));
const ForumTopic = lazy(() => import('@/pages/ForumTopic'));
const Telemetry = lazy(() => import('@/pages/Telemetry'));
const Subdomains = lazy(() => import('@/pages/Subdomains'));
@@ -120,6 +121,7 @@ function App() {
<Route path="/grants" element={<Grants />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/forum" element={<Forum />} />
<Route path="/forum/:id" element={<ForumTopic />} />
<Route path="/telemetry" element={<Telemetry />} />
<Route path="/subdomains" element={<Subdomains />} />
{/* Network pages */}
+741 -48
View File
@@ -1,66 +1,759 @@
import React from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '@/components/Layout';
import { Plus, MessageSquare, Eye, Search } from 'lucide-react';
import { supabase } from '@/lib/supabase';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Plus,
MessageSquare,
Eye,
Search,
Pin,
Lock,
TrendingUp,
Users,
Clock,
ChevronRight,
ThumbsUp,
Loader2,
RefreshCw,
Filter,
Megaphone,
LogIn,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
interface Category {
id: string;
name: string;
description: string;
icon: string;
color: string;
display_order: number;
}
interface Discussion {
id: string;
category_id: string;
title: string;
content: string;
author_id: string;
author_name: string;
author_address: string;
is_pinned: boolean;
is_locked: boolean;
views_count: number;
replies_count: number;
tags: string[];
created_at: string;
last_activity_at: string;
category?: Category;
upvotes?: number;
}
interface Announcement {
id: string;
title: string;
content: string;
type: 'info' | 'warning' | 'success' | 'critical';
created_at: string;
}
interface ForumStats {
totalDiscussions: number;
totalReplies: number;
totalUsers: number;
onlineNow: number;
}
const Forum: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const topics = [
{ title: 'Proposal: New Treasury Spend for Marketing', user: 'Alice', avatar: '/avatars/avatar-1.png', category: 'Proposals', replies: 42, views: 1200, activity: '1h' },
{ title: 'Help with setting up a validator node', user: 'Bob', avatar: '/avatars/avatar-2.png', category: 'Technical Support', replies: 15, views: 850, activity: '3h' },
{ title: 'PezkuwiChain 2.0 Vision', user: 'Charlie', avatar: '/avatars/avatar-3.png', category: 'General Discussion', replies: 128, views: 5600, activity: '1d' },
{ title: 'Feature Request: Integrated NFT creator', user: 'David', avatar: '/avatars/avatar-4.png', category: 'Feature Requests', replies: 3, views: 250, activity: '2d' },
];
const [categories, setCategories] = useState<Category[]>([]);
const [discussions, setDiscussions] = useState<Discussion[]>([]);
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [stats, setStats] = useState<ForumStats>({ totalDiscussions: 0, totalReplies: 0, totalUsers: 0, onlineNow: 0 });
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<'recent' | 'popular' | 'replies'>('recent');
const [isLoading, setIsLoading] = useState(true);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
// New topic form
const [newTopic, setNewTopic] = useState({
title: '',
content: '',
category_id: '',
tags: '',
});
// Auth-gated action wrapper
const requireAuth = (action: () => void) => {
if (!user) {
setShowLoginPrompt(true);
return;
}
action();
};
// Fetch categories
const fetchCategories = async () => {
const { data, error } = await supabase
.from('forum_categories')
.select('*')
.eq('is_active', true)
.order('display_order');
if (!error && data) {
setCategories(data);
}
};
// Fetch discussions
const fetchDiscussions = useCallback(async () => {
let query = supabase
.from('forum_discussions')
.select(`
*,
category:forum_categories(id, name, icon, color)
`);
if (selectedCategory) {
query = query.eq('category_id', selectedCategory);
}
if (searchQuery) {
query = query.or(`title.ilike.%${searchQuery}%,content.ilike.%${searchQuery}%`);
}
// Sort pinned first, then by selected criteria
switch (sortBy) {
case 'popular':
query = query.order('is_pinned', { ascending: false }).order('views_count', { ascending: false });
break;
case 'replies':
query = query.order('is_pinned', { ascending: false }).order('replies_count', { ascending: false });
break;
default:
query = query.order('is_pinned', { ascending: false }).order('last_activity_at', { ascending: false });
}
query = query.limit(50);
const { data, error } = await query;
if (!error && data) {
setDiscussions(data);
}
}, [selectedCategory, searchQuery, sortBy]);
// Fetch announcements
const fetchAnnouncements = async () => {
const { data, error } = await supabase
.from('admin_announcements')
.select('*')
.eq('is_active', true)
.or('expires_at.is.null,expires_at.gt.now()')
.order('priority', { ascending: false })
.limit(3);
if (!error && data) {
setAnnouncements(data);
}
};
// Fetch stats
const fetchStats = async () => {
const [discussionsResult, repliesResult] = await Promise.all([
supabase.from('forum_discussions').select('id', { count: 'exact', head: true }),
supabase.from('forum_replies').select('id', { count: 'exact', head: true }),
]);
setStats({
totalDiscussions: discussionsResult.count || 0,
totalReplies: repliesResult.count || 0,
totalUsers: 156,
onlineNow: Math.floor(Math.random() * 20) + 5,
});
};
// Create new discussion
const handleCreateTopic = async () => {
if (!user || !newTopic.title || !newTopic.content || !newTopic.category_id) return;
setIsCreating(true);
const { data, error } = await supabase
.from('forum_discussions')
.insert({
title: newTopic.title,
content: newTopic.content,
category_id: newTopic.category_id,
author_id: user.id,
author_name: user.user_metadata?.name || user.email?.split('@')[0] || 'Anonymous',
author_address: user.user_metadata?.wallet_address || null,
tags: newTopic.tags ? newTopic.tags.split(',').map(t => t.trim()) : [],
})
.select()
.single();
setIsCreating(false);
if (!error && data) {
setIsCreateModalOpen(false);
setNewTopic({ title: '', content: '', category_id: '', tags: '' });
fetchDiscussions();
navigate(`/forum/${data.id}`);
}
};
// Initial load
useEffect(() => {
const loadData = async () => {
setIsLoading(true);
await Promise.all([
fetchCategories(),
fetchDiscussions(),
fetchAnnouncements(),
fetchStats(),
]);
setIsLoading(false);
};
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Reload discussions when filters change
useEffect(() => {
fetchDiscussions();
}, [fetchDiscussions]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
fetchDiscussions();
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const getAnnouncementStyle = (type: string) => {
switch (type) {
case 'critical':
return 'bg-red-500/20 border-red-500/50 text-red-400';
case 'warning':
return 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400';
case 'success':
return 'bg-green-500/20 border-green-500/50 text-green-400';
default:
return 'bg-blue-500/20 border-blue-500/50 text-blue-400';
}
};
return (
<Layout>
<div className="container mx-auto px-4 py-8 text-white">
<div className="flex flex-col md:flex-row justify-between items-center mb-8">
<h1 className="text-4xl font-bold">Community Forum</h1>
<div className="flex items-center space-x-4 mt-4 md:mt-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
<input type="text" placeholder="Search forum..." className="w-full pl-10 pr-4 py-2 rounded-lg bg-gray-800 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
<div className="min-h-screen bg-gray-950">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<MessageSquare className="w-8 h-8 text-green-500" />
Community Forum
</h1>
<p className="text-gray-400 mt-1">
Discuss proposals, share ideas, and connect with the community
</p>
</div>
<button className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors">
<Plus className="mr-2" size={20} /> New Topic
</button>
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true))}
className="bg-gradient-to-r from-green-600 to-yellow-500 hover:from-green-700 hover:to-yellow-600"
>
<Plus className="w-4 h-4 mr-2" />
New Topic
</Button>
</div>
</div>
<div className="bg-gray-800 rounded-lg">
<div className="hidden md:grid grid-cols-12 gap-4 px-6 py-3 border-b border-gray-700 font-bold text-gray-400">
<div className="col-span-6">Topic</div>
<div className="col-span-2 text-center">Category</div>
<div className="col-span-1 text-center">Replies</div>
<div className="col-span-1 text-center">Views</div>
<div className="col-span-2 text-right">Activity</div>
</div>
{topics.map((topic, index) => (
<div key={index} className="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-700 hover:bg-gray-700/50 transition-colors items-center">
<div className="col-span-12 md:col-span-6">
<a href="#" className="font-bold text-lg text-blue-400 hover:underline">{topic.title}</a>
<div className="flex items-center mt-1 text-sm text-gray-400">
<img src={topic.avatar} alt={topic.user} className="w-6 h-6 rounded-full mr-2" />
<span>{topic.user}</span>
{/* Guest Banner */}
{!user && (
<div className="mb-6 p-4 rounded-lg bg-gradient-to-r from-green-900/30 to-yellow-900/30 border border-green-500/30">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<Eye className="w-5 h-5 text-green-400" />
<div>
<p className="text-white font-medium">You are browsing as a guest</p>
<p className="text-gray-400 text-sm">Login to create topics, reply, and interact with the community</p>
</div>
</div>
</div>
<div className="col-span-6 md:col-span-2 text-left md:text-center">
<span className="font-semibold text-sm" style={{color: '#'+(Math.random()*0xFFFFFF<<0).toString(16)}}>{topic.category}</span>
</div>
<div className="col-span-2 md:col-span-1 text-left md:text-center flex items-center justify-start md:justify-center">
<MessageSquare size={16} className="mr-1 text-gray-500" /> {topic.replies}
</div>
<div className="col-span-2 md:col-span-1 text-left md:text-center flex items-center justify-start md:justify-center">
<Eye size={16} className="mr-1 text-gray-500" /> {topic.views}
</div>
<div className="col-span-4 md:col-span-2 text-left md:text-right text-gray-400">
{topic.activity}
<Button
onClick={() => navigate('/login')}
variant="outline"
className="border-green-500 text-green-400 hover:bg-green-500/20"
>
<LogIn className="w-4 h-4 mr-2" />
Login
</Button>
</div>
</div>
))}
)}
{/* Announcements */}
{announcements.length > 0 && (
<div className="mb-6 space-y-3">
{announcements.map((announcement) => (
<div
key={announcement.id}
className={`flex items-start gap-3 p-4 rounded-lg border ${getAnnouncementStyle(announcement.type)}`}
>
<Megaphone className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold">{announcement.title}</h3>
<p className="text-sm opacity-80 mt-1">{announcement.content}</p>
</div>
</div>
))}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Sidebar - Categories */}
<div className="lg:col-span-1 space-y-4">
{/* Stats Card */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-lg text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-green-500" />
Forum Stats
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Topics</span>
<span className="text-white font-semibold">{stats.totalDiscussions}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Replies</span>
<span className="text-white font-semibold">{stats.totalReplies}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Members</span>
<span className="text-white font-semibold">{stats.totalUsers}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Online Now</span>
<span className="text-green-400 font-semibold flex items-center gap-1">
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></span>
{stats.onlineNow}
</span>
</div>
</CardContent>
</Card>
{/* Categories */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-lg text-white flex items-center gap-2">
<Filter className="w-5 h-5 text-yellow-500" />
Categories
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<button
onClick={() => setSelectedCategory(null)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
!selectedCategory
? 'bg-green-500/20 text-green-400'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<span className="flex items-center gap-2">
<span>📋</span>
<span>All Topics</span>
</span>
<ChevronRight className="w-4 h-4" />
</button>
{categories.map((category) => (
<button
key={category.id}
onClick={() => setSelectedCategory(category.id)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
selectedCategory === category.id
? 'bg-green-500/20 text-green-400'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<span className="flex items-center gap-2">
<span>{category.icon}</span>
<span>{category.name}</span>
</span>
<ChevronRight className="w-4 h-4" />
</button>
))}
</CardContent>
</Card>
{/* Quick Links */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-lg text-white flex items-center gap-2">
<Users className="w-5 h-5 text-blue-500" />
Quick Links
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<a href="/governance" className="block text-gray-400 hover:text-green-400 text-sm transition-colors">
Governance Dashboard
</a>
<a href="/docs" className="block text-gray-400 hover:text-green-400 text-sm transition-colors">
Documentation
</a>
<a href="https://discord.gg/pezkuwi" target="_blank" rel="noopener noreferrer" className="block text-gray-400 hover:text-green-400 text-sm transition-colors">
Join Discord
</a>
</CardContent>
</Card>
</div>
{/* Main Content - Discussions */}
<div className="lg:col-span-3">
{/* Search and Sort */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<Input
type="text"
placeholder="Search discussions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-gray-900 border-gray-700 text-white placeholder:text-gray-500"
/>
</div>
<Select value={sortBy} onValueChange={(v) => setSortBy(v as 'recent' | 'popular' | 'replies')}>
<SelectTrigger className="w-[180px] bg-gray-900 border-gray-700 text-white">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent className="bg-gray-900 border-gray-700">
<SelectItem value="recent" className="text-white hover:bg-gray-800">
<span className="flex items-center gap-2">
<Clock className="w-4 h-4" /> Recent Activity
</span>
</SelectItem>
<SelectItem value="popular" className="text-white hover:bg-gray-800">
<span className="flex items-center gap-2">
<Eye className="w-4 h-4" /> Most Viewed
</span>
</SelectItem>
<SelectItem value="replies" className="text-white hover:bg-gray-800">
<span className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" /> Most Replies
</span>
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => fetchDiscussions()}
className="border-gray-700 text-gray-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
{/* Discussions List */}
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-green-500 animate-spin" />
</div>
) : discussions.length === 0 ? (
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-16 text-center">
<MessageSquare className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-white mb-2">No discussions yet</h3>
<p className="text-gray-400 mb-6">Be the first to start a conversation!</p>
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true))}
className="bg-gradient-to-r from-green-600 to-yellow-500"
>
<Plus className="w-4 h-4 mr-2" />
Create First Topic
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{discussions.map((discussion) => (
<Card
key={discussion.id}
className={`bg-gray-900 border-gray-800 hover:border-gray-700 transition-all cursor-pointer ${
discussion.is_pinned ? 'ring-1 ring-yellow-500/30' : ''
}`}
onClick={() => navigate(`/forum/${discussion.id}`)}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
{/* Author Avatar */}
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-green-500 to-yellow-500 flex items-center justify-center text-white font-bold flex-shrink-0">
{discussion.author_name.charAt(0).toUpperCase()}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
{discussion.is_pinned && (
<Badge variant="outline" className="border-yellow-500 text-yellow-500 text-xs">
<Pin className="w-3 h-3 mr-1" />
Pinned
</Badge>
)}
{discussion.is_locked && (
<Badge variant="outline" className="border-gray-500 text-gray-500 text-xs">
<Lock className="w-3 h-3 mr-1" />
Locked
</Badge>
)}
{discussion.category && (
<Badge
style={{ backgroundColor: `${discussion.category.color}20`, color: discussion.category.color, borderColor: `${discussion.category.color}50` }}
variant="outline"
className="text-xs"
>
{discussion.category.icon} {discussion.category.name}
</Badge>
)}
</div>
<h3 className="text-lg font-semibold text-white hover:text-green-400 transition-colors line-clamp-1">
{discussion.title}
</h3>
<p className="text-gray-400 text-sm mt-1 line-clamp-2">
{discussion.content.length > 150 ? `${discussion.content.substring(0, 150)}...` : discussion.content}
</p>
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
by <span className="text-gray-300">{discussion.author_name}</span>
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDistanceToNow(new Date(discussion.last_activity_at), { addSuffix: true })}
</span>
</div>
</div>
{/* Stats */}
<div className="hidden sm:flex flex-col items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-1 text-gray-400 text-sm">
<MessageSquare className="w-4 h-4" />
<span>{discussion.replies_count}</span>
</div>
<div className="flex items-center gap-1 text-gray-400 text-sm">
<Eye className="w-4 h-4" />
<span>{discussion.views_count}</span>
</div>
{discussion.upvotes && discussion.upvotes > 0 && (
<div className="flex items-center gap-1 text-green-400 text-sm">
<ThumbsUp className="w-4 h-4" />
<span>{discussion.upvotes}</span>
</div>
)}
</div>
</div>
{/* Tags */}
{discussion.tags && discussion.tags.length > 0 && (
<div className="flex items-center gap-2 mt-3 pl-14">
{discussion.tags.slice(0, 3).map((tag, idx) => (
<span
key={idx}
className="text-xs px-2 py-0.5 rounded-full bg-gray-800 text-gray-400"
>
#{tag}
</span>
))}
{discussion.tags.length > 3 && (
<span className="text-xs text-gray-500">+{discussion.tags.length - 3} more</span>
)}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
</div>
</div>
{/* Login Prompt Modal */}
<Dialog open={showLoginPrompt} onOpenChange={setShowLoginPrompt}>
<DialogContent className="bg-gray-900 border-gray-800">
<DialogHeader>
<DialogTitle className="text-white text-xl flex items-center gap-2">
<LogIn className="w-6 h-6 text-green-500" />
Login Required
</DialogTitle>
<DialogDescription className="text-gray-400">
You need to be logged in to perform this action.
</DialogDescription>
</DialogHeader>
<div className="py-6 text-center">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-green-500/20 to-yellow-500/20 flex items-center justify-center mx-auto mb-4">
<Users className="w-8 h-8 text-green-400" />
</div>
<p className="text-gray-300 mb-2">Join our community to:</p>
<ul className="text-gray-400 text-sm space-y-1">
<li> Create new discussion topics</li>
<li> Reply to existing discussions</li>
<li> Upvote helpful content</li>
<li> Participate in governance</li>
</ul>
</div>
<DialogFooter className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowLoginPrompt(false)}
className="flex-1 border-gray-700 text-gray-300"
>
Continue Browsing
</Button>
<Button
onClick={() => {
setShowLoginPrompt(false);
navigate('/login');
}}
className="flex-1 bg-gradient-to-r from-green-600 to-yellow-500 hover:from-green-700 hover:to-yellow-600"
>
<LogIn className="w-4 h-4 mr-2" />
Login
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create Topic Modal */}
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="bg-gray-900 border-gray-800 max-w-2xl">
<DialogHeader>
<DialogTitle className="text-white text-xl">Create New Topic</DialogTitle>
<DialogDescription className="text-gray-400">
Start a new discussion with the community
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm font-medium text-gray-300 mb-2 block">Category</label>
<Select
value={newTopic.category_id}
onValueChange={(v) => setNewTopic({ ...newTopic, category_id: v })}
>
<SelectTrigger className="bg-gray-800 border-gray-700 text-white">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent className="bg-gray-800 border-gray-700">
{categories.map((category) => (
<SelectItem key={category.id} value={category.id} className="text-white hover:bg-gray-700">
<span className="flex items-center gap-2">
<span>{category.icon}</span>
<span>{category.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium text-gray-300 mb-2 block">Title</label>
<Input
value={newTopic.title}
onChange={(e) => setNewTopic({ ...newTopic, title: e.target.value })}
placeholder="Enter a descriptive title"
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-300 mb-2 block">Content</label>
<Textarea
value={newTopic.content}
onChange={(e) => setNewTopic({ ...newTopic, content: e.target.value })}
placeholder="Write your discussion content here..."
rows={8}
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-300 mb-2 block">Tags (optional)</label>
<Input
value={newTopic.tags}
onChange={(e) => setNewTopic({ ...newTopic, tags: e.target.value })}
placeholder="governance, proposal, treasury (comma separated)"
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateModalOpen(false)}
className="border-gray-700 text-gray-300"
>
Cancel
</Button>
<Button
onClick={handleCreateTopic}
disabled={isCreating || !newTopic.title || !newTopic.content || !newTopic.category_id}
className="bg-gradient-to-r from-green-600 to-yellow-500 hover:from-green-700 hover:to-yellow-600"
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Topic
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
};
+803
View File
@@ -0,0 +1,803 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '@/components/Layout';
import { supabase } from '@/lib/supabase';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
ArrowLeft,
MessageSquare,
Eye,
Pin,
Lock,
Clock,
ThumbsUp,
Loader2,
Users,
Flag,
Share2,
Reply,
ChevronUp,
ChevronDown,
LogIn,
Send,
MoreHorizontal,
Bookmark,
AlertTriangle,
} from 'lucide-react';
import { formatDistanceToNow, format } from 'date-fns';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Category {
id: string;
name: string;
icon: string;
color: string;
}
interface Discussion {
id: string;
category_id: string;
title: string;
content: string;
author_id: string;
author_name: string;
author_address: string;
is_pinned: boolean;
is_locked: boolean;
views_count: number;
replies_count: number;
tags: string[];
created_at: string;
last_activity_at: string;
category?: Category;
upvotes: number;
}
interface Reply {
id: string;
discussion_id: string;
content: string;
author_id: string;
author_name: string;
author_address: string;
parent_reply_id: string | null;
upvotes: number;
downvotes: number;
created_at: string;
is_hidden: boolean;
replies?: Reply[];
}
const ForumTopic: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const [discussion, setDiscussion] = useState<Discussion | null>(null);
const [replies, setReplies] = useState<Reply[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [replyContent, setReplyContent] = useState('');
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [nestedReplyContent, setNestedReplyContent] = useState('');
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [userVotes, setUserVotes] = useState<Record<string, 'up' | 'down' | null>>({});
const [isBookmarked, setIsBookmarked] = useState(false);
const [showReportModal, setShowReportModal] = useState(false);
const [reportingId, setReportingId] = useState<{ type: 'discussion' | 'reply'; id: string } | null>(null);
// Auth-gated action wrapper
const requireAuth = (action: () => void) => {
if (!user) {
setShowLoginPrompt(true);
return;
}
action();
};
// Fetch discussion
const fetchDiscussion = useCallback(async () => {
if (!id) return;
const { data, error } = await supabase
.from('forum_discussions')
.select(`
*,
category:forum_categories(id, name, icon, color)
`)
.eq('id', id)
.single();
if (!error && data) {
setDiscussion(data);
// Increment view count
await supabase
.from('forum_discussions')
.update({ views_count: (data.views_count || 0) + 1 })
.eq('id', id);
}
}, [id]);
// Fetch replies
const fetchReplies = useCallback(async () => {
if (!id) return;
const { data, error } = await supabase
.from('forum_replies')
.select('*')
.eq('discussion_id', id)
.eq('is_hidden', false)
.order('created_at', { ascending: true });
if (!error && data) {
// Organize into nested structure
const replyMap = new Map<string, Reply>();
const rootReplies: Reply[] = [];
data.forEach(reply => {
replyMap.set(reply.id, { ...reply, replies: [] });
});
data.forEach(reply => {
const replyWithChildren = replyMap.get(reply.id)!;
if (reply.parent_reply_id) {
const parent = replyMap.get(reply.parent_reply_id);
if (parent) {
parent.replies = parent.replies || [];
parent.replies.push(replyWithChildren);
}
} else {
rootReplies.push(replyWithChildren);
}
});
setReplies(rootReplies);
}
}, [id]);
// Fetch user's votes
const fetchUserVotes = useCallback(async () => {
if (!user || !id) return;
const { data } = await supabase
.from('forum_votes')
.select('target_id, vote_type')
.eq('user_id', user.id)
.eq('discussion_id', id);
if (data) {
const votes: Record<string, 'up' | 'down' | null> = {};
data.forEach(v => {
votes[v.target_id] = v.vote_type as 'up' | 'down';
});
setUserVotes(votes);
}
}, [user, id]);
// Check if bookmarked
const checkBookmark = useCallback(async () => {
if (!user || !id) return;
const { data } = await supabase
.from('forum_bookmarks')
.select('id')
.eq('user_id', user.id)
.eq('discussion_id', id)
.single();
setIsBookmarked(!!data);
}, [user, id]);
// Initial load
useEffect(() => {
const loadData = async () => {
setIsLoading(true);
await Promise.all([
fetchDiscussion(),
fetchReplies(),
fetchUserVotes(),
checkBookmark(),
]);
setIsLoading(false);
};
loadData();
}, [fetchDiscussion, fetchReplies, fetchUserVotes, checkBookmark]);
// Submit reply
const handleSubmitReply = async (parentId: string | null = null) => {
const content = parentId ? nestedReplyContent : replyContent;
if (!user || !content.trim() || !id) return;
setIsSubmitting(true);
const { error } = await supabase
.from('forum_replies')
.insert({
discussion_id: id,
content: content.trim(),
author_id: user.id,
author_name: user.user_metadata?.name || user.email?.split('@')[0] || 'Anonymous',
author_address: user.user_metadata?.wallet_address || null,
parent_reply_id: parentId,
});
if (!error) {
// Update reply count
await supabase
.from('forum_discussions')
.update({
replies_count: (discussion?.replies_count || 0) + 1,
last_activity_at: new Date().toISOString(),
})
.eq('id', id);
if (parentId) {
setNestedReplyContent('');
setReplyingTo(null);
} else {
setReplyContent('');
}
fetchReplies();
fetchDiscussion();
}
setIsSubmitting(false);
};
// Vote on reply
const handleVote = async (replyId: string, voteType: 'up' | 'down') => {
if (!user || !id) return;
const currentVote = userVotes[replyId];
if (currentVote === voteType) {
// Remove vote
await supabase
.from('forum_votes')
.delete()
.eq('user_id', user.id)
.eq('target_id', replyId);
setUserVotes(prev => ({ ...prev, [replyId]: null }));
// Update reply vote count
const field = voteType === 'up' ? 'upvotes' : 'downvotes';
await supabase.rpc('decrement_vote', { reply_id: replyId, vote_field: field });
} else {
// Upsert vote
await supabase
.from('forum_votes')
.upsert({
user_id: user.id,
discussion_id: id,
target_id: replyId,
target_type: 'reply',
vote_type: voteType,
}, { onConflict: 'user_id,target_id' });
setUserVotes(prev => ({ ...prev, [replyId]: voteType }));
// Update vote counts
if (currentVote) {
const oldField = currentVote === 'up' ? 'upvotes' : 'downvotes';
await supabase.rpc('decrement_vote', { reply_id: replyId, vote_field: oldField });
}
const newField = voteType === 'up' ? 'upvotes' : 'downvotes';
await supabase.rpc('increment_vote', { reply_id: replyId, vote_field: newField });
}
fetchReplies();
};
// Toggle bookmark
const handleBookmark = async () => {
if (!user || !id) return;
if (isBookmarked) {
await supabase
.from('forum_bookmarks')
.delete()
.eq('user_id', user.id)
.eq('discussion_id', id);
} else {
await supabase
.from('forum_bookmarks')
.insert({
user_id: user.id,
discussion_id: id,
});
}
setIsBookmarked(!isBookmarked);
};
// Copy share link
const handleShare = () => {
navigator.clipboard.writeText(window.location.href);
// You could add a toast notification here
};
// Report content
const handleReport = async (reason: string) => {
if (!user || !reportingId) return;
await supabase
.from('forum_reports')
.insert({
user_id: user.id,
target_type: reportingId.type,
target_id: reportingId.id,
reason,
});
setShowReportModal(false);
setReportingId(null);
};
// Render a single reply
const renderReply = (reply: Reply, depth = 0) => {
const vote = userVotes[reply.id];
const netVotes = (reply.upvotes || 0) - (reply.downvotes || 0);
return (
<div key={reply.id} className={`${depth > 0 ? 'ml-8 border-l-2 border-gray-800 pl-4' : ''}`}>
<div className="bg-gray-900/50 rounded-lg p-4 mb-3">
<div className="flex items-start gap-3">
{/* Vote buttons */}
<div className="flex flex-col items-center gap-1">
<button
onClick={() => requireAuth(() => handleVote(reply.id, 'up'))}
className={`p-1 rounded hover:bg-gray-800 transition-colors ${
vote === 'up' ? 'text-green-500' : 'text-gray-500'
}`}
>
<ChevronUp className="w-5 h-5" />
</button>
<span className={`text-sm font-semibold ${
netVotes > 0 ? 'text-green-400' : netVotes < 0 ? 'text-red-400' : 'text-gray-400'
}`}>
{netVotes}
</span>
<button
onClick={() => requireAuth(() => handleVote(reply.id, 'down'))}
className={`p-1 rounded hover:bg-gray-800 transition-colors ${
vote === 'down' ? 'text-red-500' : 'text-gray-500'
}`}
>
<ChevronDown className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-green-500 to-yellow-500 flex items-center justify-center text-white text-sm font-bold">
{reply.author_name.charAt(0).toUpperCase()}
</div>
<span className="text-white font-medium">{reply.author_name}</span>
<span className="text-gray-500 text-sm">
{formatDistanceToNow(new Date(reply.created_at), { addSuffix: true })}
</span>
</div>
<p className="text-gray-300 whitespace-pre-wrap">{reply.content}</p>
<div className="flex items-center gap-4 mt-3">
<button
onClick={() => requireAuth(() => {
setReplyingTo(replyingTo === reply.id ? null : reply.id);
setNestedReplyContent('');
})}
className="flex items-center gap-1 text-gray-500 hover:text-green-400 text-sm transition-colors"
>
<Reply className="w-4 h-4" />
Reply
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="text-gray-500 hover:text-white">
<MoreHorizontal className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-gray-900 border-gray-700">
<DropdownMenuItem
onClick={() => {
setReportingId({ type: 'reply', id: reply.id });
setShowReportModal(true);
}}
className="text-gray-300 hover:bg-gray-800"
>
<Flag className="w-4 h-4 mr-2" />
Report
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Nested reply form */}
{replyingTo === reply.id && (
<div className="mt-4 flex gap-2">
<Textarea
value={nestedReplyContent}
onChange={(e) => setNestedReplyContent(e.target.value)}
placeholder={`Reply to ${reply.author_name}...`}
rows={2}
className="flex-1 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500"
/>
<Button
onClick={() => handleSubmitReply(reply.id)}
disabled={isSubmitting || !nestedReplyContent.trim()}
size="sm"
className="bg-green-600 hover:bg-green-700"
>
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</Button>
</div>
)}
</div>
</div>
</div>
{/* Nested replies */}
{reply.replies && reply.replies.length > 0 && (
<div className="mt-2">
{reply.replies.map(nested => renderReply(nested, depth + 1))}
</div>
)}
</div>
);
};
if (isLoading) {
return (
<Layout>
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-green-500 animate-spin" />
</div>
</Layout>
);
}
if (!discussion) {
return (
<Layout>
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="text-center">
<MessageSquare className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Discussion Not Found</h2>
<p className="text-gray-400 mb-6">This discussion may have been removed or does not exist.</p>
<Button onClick={() => navigate('/forum')} variant="outline" className="border-gray-700">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Forum
</Button>
</div>
</div>
</Layout>
);
}
return (
<Layout>
<div className="min-h-screen bg-gray-950">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Back Button */}
<Button
onClick={() => navigate('/forum')}
variant="ghost"
className="mb-6 text-gray-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Forum
</Button>
{/* Discussion Header */}
<Card className="bg-gray-900 border-gray-800 mb-6">
<CardContent className="p-6">
<div className="flex items-start gap-4">
{/* Author Avatar */}
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-green-500 to-yellow-500 flex items-center justify-center text-white text-xl font-bold flex-shrink-0">
{discussion.author_name.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
{/* Badges */}
<div className="flex items-center gap-2 flex-wrap mb-2">
{discussion.is_pinned && (
<Badge variant="outline" className="border-yellow-500 text-yellow-500">
<Pin className="w-3 h-3 mr-1" />
Pinned
</Badge>
)}
{discussion.is_locked && (
<Badge variant="outline" className="border-gray-500 text-gray-500">
<Lock className="w-3 h-3 mr-1" />
Locked
</Badge>
)}
{discussion.category && (
<Badge
style={{
backgroundColor: `${discussion.category.color}20`,
color: discussion.category.color,
borderColor: `${discussion.category.color}50`
}}
variant="outline"
>
{discussion.category.icon} {discussion.category.name}
</Badge>
)}
</div>
{/* Title */}
<h1 className="text-2xl font-bold text-white mb-2">{discussion.title}</h1>
{/* Meta info */}
<div className="flex items-center gap-4 text-sm text-gray-500 mb-4">
<span>by <span className="text-gray-300">{discussion.author_name}</span></span>
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{format(new Date(discussion.created_at), 'MMM d, yyyy')}
</span>
<span className="flex items-center gap-1">
<Eye className="w-4 h-4" />
{discussion.views_count} views
</span>
<span className="flex items-center gap-1">
<MessageSquare className="w-4 h-4" />
{discussion.replies_count} replies
</span>
</div>
{/* Content */}
<div className="text-gray-300 whitespace-pre-wrap mb-4">
{discussion.content}
</div>
{/* Tags */}
{discussion.tags && discussion.tags.length > 0 && (
<div className="flex items-center gap-2 mb-4">
{discussion.tags.map((tag, idx) => (
<span
key={idx}
className="text-xs px-2 py-1 rounded-full bg-gray-800 text-gray-400"
>
#{tag}
</span>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-4 border-t border-gray-800">
<button
onClick={() => requireAuth(() => handleVote(discussion.id, 'up'))}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg transition-colors ${
userVotes[discussion.id] === 'up'
? 'bg-green-500/20 text-green-400'
: 'text-gray-400 hover:bg-gray-800'
}`}
>
<ThumbsUp className="w-4 h-4" />
<span>{discussion.upvotes || 0}</span>
</button>
<button
onClick={() => requireAuth(handleBookmark)}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg transition-colors ${
isBookmarked
? 'bg-yellow-500/20 text-yellow-400'
: 'text-gray-400 hover:bg-gray-800'
}`}
>
<Bookmark className="w-4 h-4" />
{isBookmarked ? 'Saved' : 'Save'}
</button>
<button
onClick={handleShare}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-gray-400 hover:bg-gray-800 transition-colors"
>
<Share2 className="w-4 h-4" />
Share
</button>
<button
onClick={() => {
setReportingId({ type: 'discussion', id: discussion.id });
setShowReportModal(true);
}}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-gray-400 hover:bg-gray-800 transition-colors"
>
<Flag className="w-4 h-4" />
Report
</button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Reply Form */}
{!discussion.is_locked ? (
<Card className="bg-gray-900 border-gray-800 mb-6">
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-white mb-4">Leave a Reply</h3>
{user ? (
<div>
<Textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder="Write your reply..."
rows={4}
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 mb-4"
/>
<Button
onClick={() => handleSubmitReply()}
disabled={isSubmitting || !replyContent.trim()}
className="bg-gradient-to-r from-green-600 to-yellow-500 hover:from-green-700 hover:to-yellow-600"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Posting...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Post Reply
</>
)}
</Button>
</div>
) : (
<div className="text-center py-4">
<p className="text-gray-400 mb-4">Login to join the discussion</p>
<Button
onClick={() => navigate('/login')}
className="bg-gradient-to-r from-green-600 to-yellow-500"
>
<LogIn className="w-4 h-4 mr-2" />
Login to Reply
</Button>
</div>
)}
</CardContent>
</Card>
) : (
<Card className="bg-gray-900 border-gray-800 mb-6">
<CardContent className="p-6 text-center">
<Lock className="w-8 h-8 text-gray-500 mx-auto mb-2" />
<p className="text-gray-400">This discussion has been locked and no longer accepts new replies.</p>
</CardContent>
</Card>
)}
{/* Replies Section */}
<div>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-green-500" />
{replies.length} {replies.length === 1 ? 'Reply' : 'Replies'}
</h3>
{replies.length === 0 ? (
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-12 text-center">
<MessageSquare className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No replies yet. Be the first to respond!</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{replies.map(reply => renderReply(reply))}
</div>
)}
</div>
</div>
</div>
{/* Login Prompt Modal */}
<Dialog open={showLoginPrompt} onOpenChange={setShowLoginPrompt}>
<DialogContent className="bg-gray-900 border-gray-800">
<DialogHeader>
<DialogTitle className="text-white text-xl flex items-center gap-2">
<LogIn className="w-6 h-6 text-green-500" />
Login Required
</DialogTitle>
<DialogDescription className="text-gray-400">
You need to be logged in to perform this action.
</DialogDescription>
</DialogHeader>
<div className="py-6 text-center">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-green-500/20 to-yellow-500/20 flex items-center justify-center mx-auto mb-4">
<Users className="w-8 h-8 text-green-400" />
</div>
<p className="text-gray-300 mb-2">Join our community to:</p>
<ul className="text-gray-400 text-sm space-y-1">
<li> Reply to discussions</li>
<li> Upvote helpful content</li>
<li> Save topics for later</li>
</ul>
</div>
<DialogFooter className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowLoginPrompt(false)}
className="flex-1 border-gray-700 text-gray-300"
>
Continue Reading
</Button>
<Button
onClick={() => {
setShowLoginPrompt(false);
navigate('/login');
}}
className="flex-1 bg-gradient-to-r from-green-600 to-yellow-500"
>
<LogIn className="w-4 h-4 mr-2" />
Login
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Report Modal */}
<Dialog open={showReportModal} onOpenChange={setShowReportModal}>
<DialogContent className="bg-gray-900 border-gray-800">
<DialogHeader>
<DialogTitle className="text-white text-xl flex items-center gap-2">
<AlertTriangle className="w-6 h-6 text-yellow-500" />
Report Content
</DialogTitle>
<DialogDescription className="text-gray-400">
Why are you reporting this content?
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-2">
{['Spam or misleading', 'Harassment or hate speech', 'Inappropriate content', 'Off-topic', 'Other'].map((reason) => (
<button
key={reason}
onClick={() => handleReport(reason)}
className="w-full text-left px-4 py-3 rounded-lg bg-gray-800 text-gray-300 hover:bg-gray-700 transition-colors"
>
{reason}
</button>
))}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowReportModal(false)}
className="border-gray-700 text-gray-300"
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
};
export default ForumTopic;