mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 06:47:55 +00:00
Complete modern forum UI with admin announcements and moderation
- Redesigned ForumOverview with modern, professional UI - Added admin announcements banner with 4 priority types (info/warning/success/critical) - Implemented upvote/downvote system with real-time updates - Added forum statistics dashboard showing discussions, categories, users, replies - Created category grid with visual icons and discussion counts - Enhanced discussion cards with pin/lock/trending badges - Integrated search, filtering, and sorting functionality - Added comprehensive moderation panel with: - Reports queue management - Auto-moderation settings with AI sentiment analysis - User management with warn/suspend/ban actions - Moderation stats dashboard - Created useForum hook with real-time Supabase subscriptions - All data connected to Supabase with RLS policies for security This completes the modern forum implementation as requested.
This commit is contained in:
@@ -1,105 +1,100 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { TrendingUp, TrendingDown, MessageSquare, Users, BarChart3, Search, Filter, Clock, Flame, Award } from 'lucide-react';
|
||||
import {
|
||||
MessageSquare,
|
||||
Users,
|
||||
Search,
|
||||
Filter,
|
||||
Clock,
|
||||
Flame,
|
||||
Pin,
|
||||
Lock,
|
||||
TrendingUp,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Plus,
|
||||
Megaphone,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForum } from '@/hooks/useForum';
|
||||
import { DiscussionThread } from './DiscussionThread';
|
||||
|
||||
interface Discussion {
|
||||
id: string;
|
||||
title: string;
|
||||
proposalId: string;
|
||||
author: string;
|
||||
category: string;
|
||||
replies: number;
|
||||
views: number;
|
||||
lastActivity: string;
|
||||
sentiment: number;
|
||||
trending: boolean;
|
||||
pinned: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export function ForumOverview() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { announcements, categories, discussions, loading, error, reactToDiscussion } = useForum();
|
||||
const [selectedDiscussion, setSelectedDiscussion] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState('recent');
|
||||
const [filterCategory, setFilterCategory] = useState('all');
|
||||
|
||||
const discussions: Discussion[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Treasury Allocation for Developer Grants - Q1 2024',
|
||||
proposalId: 'prop-001',
|
||||
author: 'Dr. Rojin Ahmed',
|
||||
category: 'Treasury',
|
||||
replies: 45,
|
||||
views: 1234,
|
||||
lastActivity: '2 hours ago',
|
||||
sentiment: 72,
|
||||
trending: true,
|
||||
pinned: true,
|
||||
tags: ['treasury', 'grants', 'development']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Technical Upgrade: Implementing Zero-Knowledge Proofs',
|
||||
proposalId: 'prop-002',
|
||||
author: 'Kawa Mustafa',
|
||||
category: 'Technical',
|
||||
replies: 28,
|
||||
views: 890,
|
||||
lastActivity: '5 hours ago',
|
||||
sentiment: 85,
|
||||
trending: true,
|
||||
pinned: false,
|
||||
tags: ['technical', 'zkp', 'privacy']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Community Initiative: Education Program for New Users',
|
||||
proposalId: 'prop-003',
|
||||
author: 'Dilan Karim',
|
||||
category: 'Community',
|
||||
replies: 62,
|
||||
views: 2100,
|
||||
lastActivity: '1 day ago',
|
||||
sentiment: 45,
|
||||
trending: false,
|
||||
pinned: false,
|
||||
tags: ['community', 'education', 'onboarding']
|
||||
const getAnnouncementStyle = (type: string) => {
|
||||
switch (type) {
|
||||
case 'critical':
|
||||
return {
|
||||
variant: 'destructive' as const,
|
||||
icon: AlertTriangle,
|
||||
bgClass: 'bg-red-500/10 border-red-500/20'
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
variant: 'default' as const,
|
||||
icon: AlertTriangle,
|
||||
bgClass: 'bg-yellow-500/10 border-yellow-500/20'
|
||||
};
|
||||
case 'success':
|
||||
return {
|
||||
variant: 'default' as const,
|
||||
icon: CheckCircle,
|
||||
bgClass: 'bg-green-500/10 border-green-500/20'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
variant: 'default' as const,
|
||||
icon: Info,
|
||||
bgClass: 'bg-blue-500/10 border-blue-500/20'
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
const sentimentStats = {
|
||||
positive: 42,
|
||||
neutral: 35,
|
||||
negative: 23
|
||||
};
|
||||
|
||||
const getSentimentColor = (sentiment: number) => {
|
||||
if (sentiment >= 70) return 'text-green-600';
|
||||
if (sentiment >= 40) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getSentimentIcon = (sentiment: number) => {
|
||||
if (sentiment >= 70) return <TrendingUp className="h-4 w-4" />;
|
||||
if (sentiment >= 40) return <BarChart3 className="h-4 w-4" />;
|
||||
return <TrendingDown className="h-4 w-4" />;
|
||||
};
|
||||
const filteredDiscussions = discussions
|
||||
.filter(d => {
|
||||
const matchesSearch = d.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
d.content.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = filterCategory === 'all' || d.category?.name.toLowerCase() === filterCategory.toLowerCase();
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'popular':
|
||||
return (b.upvotes || 0) - (a.upvotes || 0);
|
||||
case 'replies':
|
||||
return b.replies_count - a.replies_count;
|
||||
case 'views':
|
||||
return b.views_count - a.views_count;
|
||||
default:
|
||||
return new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedDiscussion) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedDiscussion(null)}
|
||||
>
|
||||
← Back to Forum
|
||||
@@ -109,41 +104,103 @@ export function ForumOverview() {
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">Loading forum...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sentiment Overview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Community Sentiment Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Positive</span>
|
||||
<span className="text-sm text-green-600">{sentimentStats.positive}%</span>
|
||||
</div>
|
||||
<Progress value={sentimentStats.positive} className="h-2 bg-green-100" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Neutral</span>
|
||||
<span className="text-sm text-yellow-600">{sentimentStats.neutral}%</span>
|
||||
</div>
|
||||
<Progress value={sentimentStats.neutral} className="h-2 bg-yellow-100" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Negative</span>
|
||||
<span className="text-sm text-red-600">{sentimentStats.negative}%</span>
|
||||
</div>
|
||||
<Progress value={sentimentStats.negative} className="h-2 bg-red-100" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Admin Announcements Banner */}
|
||||
{announcements.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{announcements.map((announcement) => {
|
||||
const style = getAnnouncementStyle(announcement.type);
|
||||
const Icon = style.icon;
|
||||
|
||||
{/* Search and Filters */}
|
||||
return (
|
||||
<Alert key={announcement.id} className={style.bgClass}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Megaphone className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<AlertTitle className="text-lg font-semibold mb-2">
|
||||
{announcement.title}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-sm">
|
||||
{announcement.content}
|
||||
</AlertDescription>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Posted {formatDistanceToNow(new Date(announcement.created_at), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forum Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Discussions</p>
|
||||
<p className="text-2xl font-bold">{discussions.length}</p>
|
||||
</div>
|
||||
<MessageSquare className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Categories</p>
|
||||
<p className="text-2xl font-bold">{categories.length}</p>
|
||||
</div>
|
||||
<Filter className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active Users</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{new Set(discussions.map(d => d.author_id)).size}
|
||||
</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Replies</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{discussions.reduce((sum, d) => sum + d.replies_count, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-yellow-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search, Filters & Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
@@ -158,13 +215,15 @@ export function ForumOverview() {
|
||||
</div>
|
||||
<Select value={filterCategory} onValueChange={setFilterCategory}>
|
||||
<SelectTrigger className="w-full md:w-[180px]">
|
||||
<SelectValue placeholder="Category" />
|
||||
<SelectValue placeholder="All Categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
<SelectItem value="treasury">Treasury</SelectItem>
|
||||
<SelectItem value="technical">Technical</SelectItem>
|
||||
<SelectItem value="community">Community</SelectItem>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.name.toLowerCase()}>
|
||||
{cat.icon} {cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
@@ -175,76 +234,155 @@ export function ForumOverview() {
|
||||
<SelectItem value="recent">Most Recent</SelectItem>
|
||||
<SelectItem value="popular">Most Popular</SelectItem>
|
||||
<SelectItem value="replies">Most Replies</SelectItem>
|
||||
<SelectItem value="sentiment">Best Sentiment</SelectItem>
|
||||
<SelectItem value="views">Most Viewed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{user && (
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Discussion
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Discussions List */}
|
||||
<div className="space-y-4">
|
||||
{discussions.map((discussion) => (
|
||||
<Card
|
||||
key={discussion.id}
|
||||
className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={() => setSelectedDiscussion(discussion.proposalId)}
|
||||
{/* Categories Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{categories.map((category) => (
|
||||
<Card
|
||||
key={category.id}
|
||||
className="cursor-pointer hover:shadow-lg transition-all hover:scale-105"
|
||||
onClick={() => setFilterCategory(category.name.toLowerCase())}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{discussion.pinned && (
|
||||
<Badge variant="secondary">
|
||||
📌 Pinned
|
||||
</Badge>
|
||||
)}
|
||||
{discussion.trending && (
|
||||
<Badge variant="destructive">
|
||||
<Flame className="h-3 w-3 mr-1" />
|
||||
Trending
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline">{discussion.category}</Badge>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{discussion.title}</h3>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>by {discussion.author}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{discussion.replies} replies
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{discussion.views} views
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{discussion.lastActivity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
{discussion.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center ml-6">
|
||||
<div className={`text-2xl font-bold ${getSentimentColor(discussion.sentiment)}`}>
|
||||
{discussion.sentiment}%
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-600">
|
||||
{getSentimentIcon(discussion.sentiment)}
|
||||
<span>Sentiment</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-4xl mb-2">{category.icon}</div>
|
||||
<h3 className="font-semibold">{category.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{discussions.filter(d => d.category?.id === category.id).length} discussions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Discussions List */}
|
||||
<div className="space-y-4">
|
||||
{filteredDiscussions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<MessageSquare className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No discussions found</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Try adjusting your search or filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredDiscussions.map((discussion) => (
|
||||
<Card
|
||||
key={discussion.id}
|
||||
className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={() => setSelectedDiscussion(discussion.id)}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{discussion.is_pinned && (
|
||||
<Badge variant="secondary" className="bg-yellow-500/10 text-yellow-700 border-yellow-500/20">
|
||||
<Pin className="h-3 w-3 mr-1" />
|
||||
Pinned
|
||||
</Badge>
|
||||
)}
|
||||
{discussion.is_locked && (
|
||||
<Badge variant="secondary" className="bg-red-500/10 text-red-700 border-red-500/20">
|
||||
<Lock className="h-3 w-3 mr-1" />
|
||||
Locked
|
||||
</Badge>
|
||||
)}
|
||||
{discussion.category && (
|
||||
<Badge variant="outline">
|
||||
{discussion.category.icon} {discussion.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
{(discussion.upvotes || 0) > 10 && (
|
||||
<Badge variant="destructive">
|
||||
<Flame className="h-3 w-3 mr-1" />
|
||||
Trending
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold mb-2 hover:text-primary transition-colors">
|
||||
{discussion.title}
|
||||
</h3>
|
||||
|
||||
{/* Meta Info */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground flex-wrap">
|
||||
<span>by {discussion.author_name}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{discussion.replies_count} replies
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="h-4 w-4" />
|
||||
{discussion.views_count} views
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{formatDistanceToNow(new Date(discussion.last_activity_at), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{discussion.tags && discussion.tags.length > 0 && (
|
||||
<div className="flex gap-2 mt-3 flex-wrap">
|
||||
{discussion.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Voting */}
|
||||
<div className="flex flex-col items-center gap-2 min-w-[60px]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
reactToDiscussion(discussion.id, 'upvote');
|
||||
}}
|
||||
className="hover:text-green-500"
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-lg font-bold">
|
||||
{(discussion.upvotes || 0) - (discussion.downvotes || 0)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
reactToDiscussion(discussion.id, 'downvote');
|
||||
}}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
export interface AdminAnnouncement {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: 'info' | 'warning' | 'success' | 'critical';
|
||||
priority: number;
|
||||
created_at: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface ForumCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
discussion_count?: number;
|
||||
}
|
||||
|
||||
export interface ForumDiscussion {
|
||||
id: string;
|
||||
category_id: string;
|
||||
category?: ForumCategory;
|
||||
proposal_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;
|
||||
updated_at: string;
|
||||
last_activity_at: string;
|
||||
upvotes?: number;
|
||||
downvotes?: number;
|
||||
}
|
||||
|
||||
export interface ForumReply {
|
||||
id: string;
|
||||
discussion_id: string;
|
||||
parent_reply_id?: string;
|
||||
content: string;
|
||||
author_id: string;
|
||||
author_name: string;
|
||||
author_address?: string;
|
||||
is_edited: boolean;
|
||||
edited_at?: string;
|
||||
created_at: string;
|
||||
upvotes?: number;
|
||||
downvotes?: number;
|
||||
}
|
||||
|
||||
export function useForum() {
|
||||
const [announcements, setAnnouncements] = useState<AdminAnnouncement[]>([]);
|
||||
const [categories, setCategories] = useState<ForumCategory[]>([]);
|
||||
const [discussions, setDiscussions] = useState<ForumDiscussion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchForumData();
|
||||
|
||||
// Subscribe to real-time updates
|
||||
const discussionsSubscription = supabase
|
||||
.channel('forum_discussions')
|
||||
.on('postgres_changes', {
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'forum_discussions'
|
||||
}, () => {
|
||||
fetchDiscussions();
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
const announcementsSubscription = supabase
|
||||
.channel('admin_announcements')
|
||||
.on('postgres_changes', {
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'admin_announcements'
|
||||
}, () => {
|
||||
fetchAnnouncements();
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
discussionsSubscription.unsubscribe();
|
||||
announcementsSubscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchForumData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
fetchAnnouncements(),
|
||||
fetchCategories(),
|
||||
fetchDiscussions()
|
||||
]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchAnnouncements = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_announcements')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.or(`expires_at.is.null,expires_at.gt.${new Date().toISOString()}`)
|
||||
.order('priority', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(3);
|
||||
|
||||
if (error) throw error;
|
||||
setAnnouncements(data || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching announcements:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('forum_categories')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('display_order');
|
||||
|
||||
if (error) throw error;
|
||||
setCategories(data || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch categories');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDiscussions = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('forum_discussions')
|
||||
.select(`
|
||||
*,
|
||||
category:forum_categories(*)
|
||||
`)
|
||||
.order('is_pinned', { ascending: false })
|
||||
.order('last_activity_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Fetch reaction counts for each discussion
|
||||
const discussionsWithReactions = await Promise.all(
|
||||
(data || []).map(async (discussion) => {
|
||||
const { data: reactions } = await supabase
|
||||
.from('forum_reactions')
|
||||
.select('reaction_type')
|
||||
.eq('discussion_id', discussion.id);
|
||||
|
||||
const upvotes = reactions?.filter(r => r.reaction_type === 'upvote').length || 0;
|
||||
const downvotes = reactions?.filter(r => r.reaction_type === 'downvote').length || 0;
|
||||
|
||||
return {
|
||||
...discussion,
|
||||
upvotes,
|
||||
downvotes
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setDiscussions(discussionsWithReactions);
|
||||
} catch (err) {
|
||||
console.error('Error fetching discussions:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch discussions');
|
||||
}
|
||||
};
|
||||
|
||||
const createDiscussion = async (discussionData: {
|
||||
category_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags?: string[];
|
||||
proposal_id?: string;
|
||||
}) => {
|
||||
try {
|
||||
const user = (await supabase.auth.getUser()).data.user;
|
||||
if (!user) throw new Error('User not authenticated');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('forum_discussions')
|
||||
.insert({
|
||||
...discussionData,
|
||||
author_id: user.id,
|
||||
author_name: user.email || 'Anonymous'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
await fetchDiscussions();
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Error creating discussion:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const reactToDiscussion = async (discussionId: string, reactionType: 'upvote' | 'downvote') => {
|
||||
try {
|
||||
const user = (await supabase.auth.getUser()).data.user;
|
||||
if (!user) throw new Error('User not authenticated');
|
||||
|
||||
// Check if user already reacted
|
||||
const { data: existing } = await supabase
|
||||
.from('forum_reactions')
|
||||
.select('*')
|
||||
.eq('discussion_id', discussionId)
|
||||
.eq('user_id', user.id)
|
||||
.eq('reaction_type', reactionType)
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
// Remove reaction
|
||||
await supabase
|
||||
.from('forum_reactions')
|
||||
.delete()
|
||||
.eq('id', existing.id);
|
||||
} else {
|
||||
// Add reaction (remove opposite reaction first)
|
||||
const oppositeType = reactionType === 'upvote' ? 'downvote' : 'upvote';
|
||||
await supabase
|
||||
.from('forum_reactions')
|
||||
.delete()
|
||||
.eq('discussion_id', discussionId)
|
||||
.eq('user_id', user.id)
|
||||
.eq('reaction_type', oppositeType);
|
||||
|
||||
await supabase
|
||||
.from('forum_reactions')
|
||||
.insert({
|
||||
discussion_id: discussionId,
|
||||
user_id: user.id,
|
||||
reaction_type: reactionType
|
||||
});
|
||||
}
|
||||
|
||||
await fetchDiscussions();
|
||||
} catch (err) {
|
||||
console.error('Error reacting to discussion:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
announcements,
|
||||
categories,
|
||||
discussions,
|
||||
loading,
|
||||
error,
|
||||
createDiscussion,
|
||||
reactToDiscussion,
|
||||
refreshData: fetchForumData
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user