mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-29 23:57:59 +00:00
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:
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user