1281 lines
44 KiB
TypeScript
Raw Permalink Normal View History

2025-03-25 03:52:30 -04:00
"use client"
import type React from "react"
import { useState, useRef, useEffect } from "react"
import {
ArrowUpDown,
Filter,
Search,
ThumbsDown,
ThumbsUp,
SortAsc,
SortDesc,
CheckCircle,
X,
Clock,
Check,
Flag,
AlertTriangle,
FileText,
Loader2,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Progress } from "@/components/ui/progress"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useWalletStore } from "@/lib/wallet-store"
import { cn } from "@/lib/utils"
import { toast } from "@/components/ui/use-toast"
import { ToastAction } from "@/components/ui/toast"
import { ethers } from "ethers"
import { PERSPECTIVE_CONTRACT_ABI, PERSPECTIVE_CONTRACT_ADDRESS } from "@/lib/constants"
// Define the insight type
type Insight = {
id: number;
title: string;
description: string;
category: string;
votes: { yes: number; no: number };
status: "voting" | "consensus" | "rejected";
dateAdded: string;
isVerified: boolean;
moderationStatus: "flagged" | "approved" | "rejected" | null;
flags: string[];
aiFlags: { reason: string; confidence: number } | null;
};
// Mock data for insights
const mockInsights: Insight[] = [
{
id: 1,
title: "Increase Park Funding",
description: "Allocate 15% more funding to local parks for maintenance and new facilities",
category: "Environmental Policy",
votes: { yes: 245, no: 32 },
status: "voting", // voting, consensus, rejected
dateAdded: "2024-02-15",
isVerified: true,
moderationStatus: null, // null, 'flagged', 'approved', 'rejected'
flags: [], // list of flag reasons by users
aiFlags: null, // null or { reason: string, confidence: number }
},
{
id: 2,
title: "Public Transportation Expansion",
description: "Extend bus routes to underserved neighborhoods and increase service frequency",
category: "Infrastructure",
votes: { yes: 189, no: 45 },
status: "voting",
dateAdded: "2024-02-10",
isVerified: true,
moderationStatus: null,
flags: [],
aiFlags: null,
},
{
id: 3,
title: "After-School Programs",
description: "Create free after-school programs for K-8 students in public schools",
category: "Education",
votes: { yes: 312, no: 28 },
status: "consensus",
dateAdded: "2024-01-28",
isVerified: true,
moderationStatus: null,
flags: [],
aiFlags: null,
},
{
id: 4,
title: "Community Health Clinics",
description: "Establish walk-in clinics in underserved areas with sliding scale fees",
category: "Healthcare",
votes: { yes: 278, no: 42 },
status: "consensus",
dateAdded: "2024-01-22",
isVerified: true,
moderationStatus: null,
flags: [],
aiFlags: null,
},
{
id: 5,
title: "Affordable Housing Initiative",
description: "Require 20% affordable units in new residential developments over 50 units",
category: "Housing",
votes: { yes: 156, no: 98 },
status: "voting",
dateAdded: "2024-01-15",
isVerified: false,
moderationStatus: null,
flags: [],
aiFlags: null,
},
{
id: 6,
title: "Bike Lane Network",
description: "Create a connected network of protected bike lanes throughout the city",
category: "Infrastructure",
votes: { yes: 203, no: 87 },
status: "voting",
dateAdded: "2024-01-05",
isVerified: true,
moderationStatus: null,
flags: [],
aiFlags: null,
},
{
id: 7,
title: "Universal Basic Income Pilot",
description: "Launch a 12-month UBI pilot program for 500 residents",
category: "Economic Policy",
votes: { yes: 102, no: 178 },
status: "voting",
dateAdded: "2024-02-01",
isVerified: false,
moderationStatus: "flagged",
flags: ["Inappropriate", "Other"],
aiFlags: null,
},
{
id: 8,
title: "City Wi-Fi Network Expansion",
description: "Free public Wi-Fi in all public spaces and municipal buildings",
category: "Technology",
votes: { yes: 187, no: 23 },
status: "voting",
dateAdded: "2024-01-12",
isVerified: false,
moderationStatus: null,
flags: [],
aiFlags: {
reason: "Repetitive text patterns",
confidence: 0.87
},
},
{
id: 9,
title: "Green Roof Initiative",
description: "Require all new commercial buildings to include green roof elements",
category: "Environmental Policy",
votes: { yes: 221, no: 67 },
status: "voting",
dateAdded: "2024-02-03",
isVerified: false,
moderationStatus: "flagged",
flags: ["Spam"],
aiFlags: {
reason: "Generic content",
confidence: 0.76
},
}
]
export default function InsightsDashboard() {
const [insights, setInsights] = useState(mockInsights)
const [activeTab, setActiveTab] = useState("all")
const [searchQuery, setSearchQuery] = useState("")
const [categoryFilter, setCategoryFilter] = useState("all")
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc")
const [sortBy, setSortBy] = useState<"votes" | "date">("votes")
const { walletConnected, isVerified } = useWalletStore()
const [userVotes, setUserVotes] = useState<Record<number, "yes" | "no" | null>>({})
const [pendingToast, setPendingToast] = useState<{ type: "single" | "batch"; voteType?: "yes" | "no"; insightId?: number; batchSize?: number } | null>(null)
const lastToastRef = useRef<{ type: string, id?: number, time: number }>({ type: "", time: 0 })
// Batch voting state
const [batchMode, setBatchMode] = useState(false)
const [selectedInsights, setSelectedInsights] = useState<Record<number, "yes" | "no">>({})
const [isSubmittingBatch, setIsSubmittingBatch] = useState(false)
const [countdownSeconds, setCountdownSeconds] = useState(5)
const countdownRef = useRef<NodeJS.Timeout | null>(null)
// Moderation and visibility state
const [showUnverified, setShowUnverified] = useState(false)
const [showModQueue, setShowModQueue] = useState(false)
const [flaggingInsight, setFlaggingInsight] = useState<number | null>(null)
const [flagReason, setFlagReason] = useState<"Spam" | "Inappropriate" | "Other" | null>(null)
const [flagDetails, setFlagDetails] = useState("")
const [isFlagging, setIsFlagging] = useState(false)
const [submittingModerationAction, setSubmittingModerationAction] = useState(false)
const filteredInsights = insights.filter((insight) => {
// Filter by tab
if (activeTab === "consensus" && insight.status !== "consensus") return false
if (activeTab === "voting" && insight.status !== "voting") return false
if (activeTab === "moderation" && insight.moderationStatus !== "flagged") return false
// Filter by unverified visibility
if (!showUnverified && !insight.isVerified) return false
// Filter by search query
if (
searchQuery &&
!insight.title.toLowerCase().includes(searchQuery.toLowerCase()) &&
!insight.description.toLowerCase().includes(searchQuery.toLowerCase())
)
return false
// Filter by category
if (categoryFilter !== "all" && insight.category !== categoryFilter) return false
return true
})
const sortInsights = (insights: typeof filteredInsights) => {
return [...insights].sort((a, b) => {
if (sortBy === "votes") {
const totalVotesA = a.votes.yes + a.votes.no
const totalVotesB = b.votes.yes + b.votes.no
return sortOrder === "desc" ? totalVotesB - totalVotesA : totalVotesA - totalVotesB
} else {
// Assuming each insight has a dateAdded property
const dateA = new Date(a.dateAdded || "2023-01-01").getTime()
const dateB = new Date(b.dateAdded || "2023-01-01").getTime()
return sortOrder === "desc" ? dateB - dateA : dateA - dateB
}
})
}
const sortedInsights = sortInsights(filteredInsights)
// Count selected insights
const selectedCount = Object.keys(selectedInsights).length
// Handle individual vote
const handleVote = (id: number, voteType: "yes" | "no") => {
// Record the user's vote
setUserVotes((prev) => ({
...prev,
[id]: voteType,
}))
// Update the insight's vote count
setInsights(
insights.map((insight) => {
if (insight.id === id) {
const updatedVotes = {
...insight.votes,
[voteType]: insight.votes[voteType] + 1,
}
// Check if consensus is reached (75% yes votes)
const totalVotes = updatedVotes.yes + updatedVotes.no
const status = updatedVotes.yes / totalVotes > 0.75 ? "consensus" : "voting"
return { ...insight, votes: updatedVotes, status }
}
return insight
}),
)
// Queue toast for individual votes (not batch mode)
if (!batchMode) {
setPendingToast({ type: "single", voteType, insightId: id })
}
}
// Toggle batch mode
const toggleBatchMode = () => {
if (batchMode) {
// Clear selections when exiting batch mode
setSelectedInsights({})
}
setBatchMode(!batchMode)
}
// Toggle selection of an insight in batch mode
const toggleSelection = (id: number, voteType: "yes" | "no") => {
// Find the insight
const insight = insights.find((i) => i.id === id)
// Don't allow selection if insight has reached consensus
if (insight?.status === "consensus") return
setSelectedInsights((prev) => {
const newSelections = { ...prev }
// If already selected with this vote type, remove it
if (newSelections[id] === voteType) {
delete newSelections[id]
} else {
// Otherwise add/update it
newSelections[id] = voteType
}
return newSelections
})
}
// Start batch submission with countdown
const startBatchSubmission = () => {
if (selectedCount === 0) return
setIsSubmittingBatch(true)
setCountdownSeconds(5)
// Start countdown
countdownRef.current = setInterval(() => {
setCountdownSeconds((prev) => {
if (prev <= 1) {
// Clear interval when countdown reaches 0
if (countdownRef.current) clearInterval(countdownRef.current)
// Process the batch
processBatchVotes()
return 0
}
return prev - 1
})
}, 1000)
}
// Cancel batch submission
const cancelBatchSubmission = () => {
if (countdownRef.current) {
clearInterval(countdownRef.current)
}
setIsSubmittingBatch(false)
}
// Process all votes in the batch
const processBatchVotes = () => {
const batchSize = Object.keys(selectedInsights).length
if (batchSize === 0) return
// Apply all votes
Object.entries(selectedInsights).forEach(([idStr, voteType]) => {
const id = Number.parseInt(idStr)
handleVote(id, voteType)
})
// Queue toast for batch submission
setPendingToast({ type: "batch", batchSize })
// Reset batch state
setSelectedInsights({})
setIsSubmittingBatch(false)
}
// Handle toast notifications
useEffect(() => {
if (pendingToast) {
// Check for duplicate toast prevention (only show if different from last toast or more than 2 seconds passed)
const now = Date.now()
const isDuplicate =
pendingToast.type === lastToastRef.current.type &&
(pendingToast.type === "single" ? pendingToast.insightId === lastToastRef.current.id : true) &&
now - lastToastRef.current.time < 2000;
if (!isDuplicate) {
if (pendingToast.type === "single") {
const insight = insights.find(i => i.id === pendingToast.insightId)
toast({
title: "Vote submitted",
description: `You voted ${pendingToast.voteType} on "${insight?.title}"`,
action: <ToastAction altText="OK">OK</ToastAction>,
})
// Update last toast reference
lastToastRef.current = {
type: "single",
id: pendingToast.insightId,
time: now
}
} else if (pendingToast.type === "batch") {
toast({
title: "Batch votes submitted",
description: `Successfully submitted ${pendingToast.batchSize} votes`,
action: <ToastAction altText="OK">OK</ToastAction>,
})
// Update last toast reference
lastToastRef.current = {
type: "batch",
time: now
}
}
}
setPendingToast(null)
}
}, [pendingToast, insights])
// Enhanced HoldButton component with improved animation and feedback
function HoldButton({
children,
onComplete,
disabled = false,
variant = "default",
className = "",
holdTime = 3000, // 3 seconds
insightId,
voteType,
}: {
children: React.ReactNode
onComplete: () => void
disabled?: boolean
variant?: "default" | "outline"
className?: string
holdTime?: number
insightId: number
voteType: "yes" | "no"
}) {
const [isHolding, setIsHolding] = useState(false)
const [progress, setProgress] = useState(0)
const [completed, setCompleted] = useState(false)
const [localVoted, setLocalVoted] = useState(false) // Track local vote state
const timerRef = useRef<NodeJS.Timeout | null>(null)
const startTimeRef = useRef<number>(0)
const animationRef = useRef<number | null>(null)
// Check if this insight has already been voted on
const hasVoted = userVotes[insightId] !== undefined || localVoted
const isThisVote = userVotes[insightId] === voteType
// Reset completed state when the insight changes
useEffect(() => {
setCompleted(isThisVote)
}, [isThisVote])
const startHold = () => {
if (disabled || hasVoted) return
setIsHolding(true)
setCompleted(false)
startTimeRef.current = Date.now()
// Use requestAnimationFrame for smoother animation
const animate = () => {
const elapsed = Date.now() - startTimeRef.current
const newProgress = Math.min((elapsed / holdTime) * 100, 100)
setProgress(newProgress)
if (newProgress >= 100) {
setCompleted(true)
// Add a small delay before triggering the action for visual feedback
timerRef.current = setTimeout(() => {
setLocalVoted(true) // Set local vote state immediately
onComplete()
setIsHolding(false)
setProgress(0)
}, 300)
} else {
animationRef.current = requestAnimationFrame(animate)
}
}
animationRef.current = requestAnimationFrame(animate)
}
const endHold = () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
if (!completed) {
setIsHolding(false)
setProgress(0)
}
}
useEffect(() => {
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [])
// Get the base button color based on variant
const baseColor =
variant === "default" ? "bg-primary text-primary-foreground" : "bg-background text-foreground border border-input"
// Get the success color
const successColor = "bg-green-500 text-white"
return (
<Button
variant={variant}
size="sm"
className={cn(
"relative overflow-hidden transition-all",
(completed || isThisVote) && "ring-2 ring-green-500 ring-opacity-50",
hasVoted && !isThisVote && "opacity-50",
!isHolding && !completed && !isThisVote && baseColor,
(completed || isThisVote) && successColor,
className,
)}
style={{
// Apply dynamic background color based on progress
...(isHolding && {
background: `linear-gradient(to right,
${variant === "default" ? "#10b981" : "#10b981"} ${progress}%,
${variant === "default" ? "hsl(var(--primary))" : "hsl(var(--background))"} ${progress}%)`,
borderColor: progress > 50 ? "#10b981" : "",
color: progress > 70 ? "white" : "",
}),
}}
disabled={disabled || !walletConnected || hasVoted}
onMouseDown={startHold}
onMouseUp={endHold}
onMouseLeave={endHold}
onTouchStart={startHold}
onTouchEnd={endHold}
onTouchCancel={endHold}
>
<div
className={cn(
"relative z-10 flex items-center justify-center w-full",
(completed || isThisVote) && "scale-110 transition-transform duration-200",
)}
>
{completed || isThisVote ? <CheckCircle className="h-6 w-6 animate-pulse text-white" /> : children}
</div>
</Button>
)
}
// Batch selection button component
function BatchSelectionButton({
children,
insightId,
voteType,
className = "",
variant = "default",
disabled = false,
}: {
children: React.ReactNode
insightId: number
voteType: "yes" | "no"
className?: string
variant?: "default" | "outline"
disabled?: boolean
}) {
// Check if this insight is selected with this vote type
const isSelected = selectedInsights[insightId] === voteType
// Check if this insight has already been voted on
const hasVoted = userVotes[insightId] !== undefined
// Base styles
const baseColor =
variant === "default" ? "bg-primary text-primary-foreground" : "bg-background text-foreground border border-input"
const selectedColor = voteType === "yes" ? "bg-green-500 text-white" : "bg-red-500 text-white"
return (
<Button
variant={variant}
size="sm"
className={cn(
"relative overflow-hidden transition-all",
isSelected && "ring-2 ring-opacity-50",
isSelected && voteType === "yes" && "ring-green-500",
isSelected && voteType === "no" && "ring-red-500",
hasVoted && "opacity-50 cursor-not-allowed",
disabled && "opacity-50 cursor-not-allowed",
!isSelected && !hasVoted && !disabled && baseColor,
isSelected && selectedColor,
className,
)}
disabled={hasVoted || isSubmittingBatch || disabled}
onClick={() => !hasVoted && !disabled && toggleSelection(insightId, voteType)}
>
<div
className={cn(
"relative z-10 flex items-center justify-center w-full",
isSelected && "scale-110 transition-transform duration-200",
)}
>
{isSelected ? <Check className="h-5 w-5 mr-1" /> : null}
{children}
</div>
</Button>
)
}
// Handle flagging an insight
const handleFlagInsight = async () => {
if (!flaggingInsight || !flagReason) return;
setIsFlagging(true);
try {
const insight = insights.find(i => i.id === flaggingInsight);
if (!insight) throw new Error("Insight not found");
// Call smart contract
const provider = new ethers.providers.Web3Provider(
window.ethereum as ethers.providers.ExternalProvider
);
const signer = provider.getSigner();
const contract = new ethers.Contract(
PERSPECTIVE_CONTRACT_ADDRESS,
PERSPECTIVE_CONTRACT_ABI,
signer
);
// Submit flag to blockchain
const tx = await contract.flagInsight(
flaggingInsight,
flagReason,
flagDetails || "",
{ gasLimit: 500000 }
);
// Wait for transaction
await tx.wait();
// Update local state
setInsights(insights.map(insight => {
if (insight.id === flaggingInsight) {
return {
...insight,
moderationStatus: "flagged",
flags: [...insight.flags, flagReason]
};
}
return insight;
}));
// Show toast
toast({
title: "Flag submitted",
description: `You flagged "${insight.title}" as ${flagReason}`,
action: <ToastAction altText="OK">OK</ToastAction>,
});
// Reset state
setFlaggingInsight(null);
setFlagReason(null);
setFlagDetails("");
} catch (error) {
console.error("Error flagging insight:", error);
toast({
title: "Error",
description: "Failed to submit flag. Please try again.",
variant: "destructive",
});
} finally {
setIsFlagging(false);
}
};
// Handle moderation decision
const handleModerateInsight = async (insightId: number, approved: boolean) => {
setSubmittingModerationAction(true);
try {
// Call smart contract
const provider = new ethers.providers.Web3Provider(
window.ethereum as ethers.providers.ExternalProvider
);
const signer = provider.getSigner();
const contract = new ethers.Contract(
PERSPECTIVE_CONTRACT_ADDRESS,
PERSPECTIVE_CONTRACT_ABI,
signer
);
// Submit moderation decision to blockchain
const tx = await contract.moderateInsight(
insightId,
approved,
{ gasLimit: 300000 }
);
// Wait for transaction
await tx.wait();
// Update local state
setInsights(insights.map(insight => {
if (insight.id === insightId) {
return {
...insight,
moderationStatus: approved ? "approved" : "rejected"
};
}
return insight;
}));
// Show toast
const insight = insights.find(i => i.id === insightId);
toast({
title: approved ? "Insight approved" : "Insight rejected",
description: `You ${approved ? "approved" : "rejected"} "${insight?.title}"`,
action: <ToastAction altText="OK">OK</ToastAction>,
});
} catch (error) {
console.error("Error moderating insight:", error);
toast({
title: "Error",
description: "Failed to submit moderation decision. Please try again.",
variant: "destructive",
});
} finally {
setSubmittingModerationAction(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Insights Dashboard</h1>
<p className="mt-2 text-muted-foreground">
Review and vote on AI-generated insights derived from community perspectives.
</p>
</div>
{/* Batch Mode Toggle */}
{walletConnected && (
<div className="mb-6 flex items-center justify-between bg-muted/30 p-3 rounded-lg">
<div className="flex items-center space-x-2">
<Switch
id="batch-mode"
checked={batchMode}
onCheckedChange={toggleBatchMode}
disabled={isSubmittingBatch}
/>
<Label htmlFor="batch-mode" className="font-medium">
Batch Voting Mode
</Label>
<span className="text-sm text-muted-foreground">
{batchMode ? "Select multiple insights to vote on at once" : "Vote on insights individually"}
</span>
</div>
{batchMode && selectedCount > 0 && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-background">
{selectedCount} selected
</Badge>
{isSubmittingBatch ? (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Submitting in {countdownSeconds}s</span>
<Button size="sm" variant="destructive" onClick={cancelBatchSubmission}>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
) : (
<Button size="sm" onClick={startBatchSubmission}>
Submit Votes ({selectedCount})
</Button>
)}
</div>
)}
</div>
)}
{/* Filters and Search */}
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="relative w-full md:w-72">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search insights..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder="Filter by category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="Environmental Policy">Environmental</SelectItem>
<SelectItem value="Infrastructure">Infrastructure</SelectItem>
<SelectItem value="Education">Education</SelectItem>
<SelectItem value="Healthcare">Healthcare</SelectItem>
<SelectItem value="Housing">Housing</SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4" />
Sort by {sortBy === "votes" ? "Votes" : "Date"}
{sortOrder === "desc" ? <SortDesc className="ml-1 h-4 w-4" /> : <SortAsc className="ml-1 h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setSortBy("votes")}>Votes {sortBy === "votes" && "✓"}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("date")}>Date {sortBy === "date" && "✓"}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setSortOrder(sortOrder === "desc" ? "asc" : "desc")}>
{sortOrder === "desc" ? "Ascending" : "Descending"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Tabs */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab} className="w-full sm:w-auto">
<TabsList className={cn(
"grid w-full",
isVerified
? "grid-cols-4 sm:grid-cols-4"
: "grid-cols-3 sm:grid-cols-3"
)}>
<TabsTrigger value="all">All Insights</TabsTrigger>
<TabsTrigger value="voting">Voting Open</TabsTrigger>
<TabsTrigger value="consensus">Consensus</TabsTrigger>
{isVerified && (
<TabsTrigger value="moderation" className="relative">
Moderation
{insights.filter(i => i.moderationStatus === "flagged").length > 0 && (
<Badge className="absolute -top-2 -right-2 bg-red-500 text-white text-xs min-w-6 h-6 flex items-center justify-center">
{insights.filter(i => i.moderationStatus === "flagged").length}
</Badge>
)}
</TabsTrigger>
)}
</TabsList>
</Tabs>
<div className="flex items-center space-x-2">
<Switch
id="show-unverified"
checked={showUnverified}
onCheckedChange={setShowUnverified}
/>
<Label htmlFor="show-unverified" className="text-sm font-medium">
Show Unverified Insights
</Label>
</div>
</div>
{!walletConnected && (
<div className="mb-6 rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
<p className="text-sm font-medium">Connect your wallet to vote on insights</p>
<p className="mt-1 text-xs">Your vote helps shape community consensus and policy decisions</p>
</div>
)}
{/* Batch submission overlay */}
{isSubmittingBatch && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-card p-6 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center">
<Clock className="h-12 w-12 mx-auto mb-4 text-primary animate-pulse" />
<h2 className="text-xl font-bold mb-2">Submitting Votes</h2>
<p className="text-muted-foreground mb-4">
Your votes will be submitted in {countdownSeconds} seconds. You can cancel if you need to make changes.
</p>
<div className="mb-4">
<Progress value={(5 - countdownSeconds) * 20} className="h-2" />
</div>
<div className="flex flex-col gap-4 max-h-40 overflow-y-auto mb-4">
{Object.entries(selectedInsights).map(([idStr, voteType]) => {
const id = Number.parseInt(idStr)
const insight = insights.find((i) => i.id === id)
if (!insight) return null
return (
<div key={id} className="flex items-center justify-between text-sm p-2 bg-muted/30 rounded">
<span className="truncate max-w-[200px]">{insight.title}</span>
<Badge className={voteType === "yes" ? "bg-green-500" : "bg-red-500"}>
{voteType === "yes" ? "Yes" : "No"}
</Badge>
</div>
)
})}
</div>
<div className="flex gap-4 justify-center">
<Button variant="outline" onClick={cancelBatchSubmission}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button onClick={processBatchVotes}>
<Check className="mr-2 h-4 w-4" />
Submit Now
</Button>
</div>
</div>
</div>
</div>
)}
{batchMode && (
<div className="mb-4 text-sm text-muted-foreground">
<p>Note: Insights that have already reached consensus cannot be selected for batch voting.</p>
</div>
)}
{/* Insights Grid */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{activeTab === "moderation" ? (
// Moderation queue view
sortedInsights.map((insight) => (
<Card
key={insight.id}
className={cn(
"flex flex-col transition-all duration-200 relative"
)}
>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="group relative flex items-center gap-2">
{insight.title}
{!insight.isVerified && (
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200">
Unverified
</Badge>
)}
</CardTitle>
<CardDescription className="mt-1">{insight.category}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-muted-foreground">{insight.description}</p>
<div className="mt-4 space-y-4">
{/* Flags from users */}
{insight.flags.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">User Flags:</p>
<div className="flex flex-wrap gap-2">
{insight.flags.map((flag, index) => (
<Badge key={index} variant="outline" className="bg-red-50 text-red-700 border-red-200">
{flag}
</Badge>
))}
</div>
</div>
)}
{/* AI Flags */}
{insight.aiFlags && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">AI Flagged:</p>
<Badge className="bg-red-500 text-white">
{insight.aiFlags.confidence >= 0.8 ? "High" : "Medium"} Confidence
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Reason: {insight.aiFlags.reason}
</p>
<Button variant="link" size="sm" className="h-auto p-0">
View Details
</Button>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
variant="outline"
className="w-[48%] border-red-200 text-red-700 hover:bg-red-50 hover:text-red-800"
onClick={() => handleModerateInsight(insight.id, false)}
disabled={submittingModerationAction}
>
<X className="mr-2 h-4 w-4" />
Reject
</Button>
<Button
className="w-[48%] bg-green-600 hover:bg-green-700"
onClick={() => handleModerateInsight(insight.id, true)}
disabled={submittingModerationAction}
>
<Check className="mr-2 h-4 w-4" />
Approve
</Button>
</CardFooter>
</Card>
))
) : (
// Regular insights view
sortedInsights.map((insight) => (
<Card
key={insight.id}
className={cn(
"flex flex-col transition-all duration-200 relative",
selectedInsights[insight.id] === "yes" && "ring-2 ring-green-500",
selectedInsights[insight.id] === "no" && "ring-2 ring-red-500",
isSubmittingBatch && selectedInsights[insight.id] && "animate-pulse",
!insight.isVerified && "border-amber-200",
insight.moderationStatus === "flagged" && "border-red-200",
)}
>
{/* Verification and moderation status badges */}
<div className="absolute -top-2 -right-2 flex gap-1">
{!insight.isVerified && (
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200">
Unverified
</Badge>
)}
{insight.aiFlags && (
<Badge className="bg-red-500 text-white">
AI Flagged
</Badge>
)}
</div>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>{insight.title}</CardTitle>
<CardDescription className="mt-1">{insight.category}</CardDescription>
</div>
<Badge
variant={insight.status === "consensus" ? "default" : "outline"}
className={
insight.status === "voting"
? "bg-amber-100 text-amber-800 hover:bg-amber-200 dark:bg-amber-900/60 dark:text-amber-100 dark:hover:bg-amber-800"
: ""
}
>
{insight.status === "consensus" ? "Consensus" : "Voting"}
</Badge>
</div>
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-muted-foreground">{insight.description}</p>
{/* AI Flag details */}
{insight.aiFlags && (
<div className="mt-3 p-2 bg-red-50 border border-red-200 rounded-md text-sm">
<p className="text-red-700 font-medium">
AI Flag: {insight.aiFlags.reason}
</p>
<Button variant="link" size="sm" className="h-auto p-0 text-red-700">
View Details
</Button>
</div>
)}
<div className="mt-4">
<div className="mb-1 flex items-center justify-between text-xs">
<span>Yes: {insight.votes.yes}</span>
<span>No: {insight.votes.no}</span>
</div>
<Progress value={(insight.votes.yes / (insight.votes.yes + insight.votes.no)) * 100} className="h-2" />
</div>
{/* Flag button for verified users */}
{isVerified && !insight.isVerified && (
<div className="mt-4 flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => setFlaggingInsight(insight.id)}
>
<Flag className="h-4 w-4 mr-1" />
Flag
</Button>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between">
{batchMode ? (
// Batch mode voting buttons
<>
<BatchSelectionButton
variant="outline"
className="w-[48%]"
insightId={insight.id}
voteType="no"
disabled={insight.status === "consensus"}
>
<ThumbsDown className="mr-2 h-4 w-4" />
No
</BatchSelectionButton>
<BatchSelectionButton
className="w-[48%]"
insightId={insight.id}
voteType="yes"
disabled={insight.status === "consensus"}
>
<ThumbsUp className="mr-2 h-4 w-4" />
Yes
</BatchSelectionButton>
</>
) : (
// Individual voting buttons
<>
<HoldButton
variant="outline"
className="w-[48%]"
onComplete={() => handleVote(insight.id, "no")}
disabled={insight.status === "consensus"}
insightId={insight.id}
voteType="no"
>
<ThumbsDown className="mr-2 h-4 w-4" />
No
</HoldButton>
<HoldButton
className="w-[48%]"
onComplete={() => handleVote(insight.id, "yes")}
disabled={insight.status === "consensus"}
insightId={insight.id}
voteType="yes"
>
<ThumbsUp className="mr-2 h-4 w-4" />
Yes
</HoldButton>
</>
)}
</CardFooter>
</Card>
))
)}
</div>
{filteredInsights.length === 0 && (
<div className="mt-12 text-center">
<p className="text-lg font-medium">No insights match your filters</p>
<p className="mt-2 text-muted-foreground">Try adjusting your search or filters</p>
<Button
variant="outline"
className="mt-4"
onClick={() => {
setSearchQuery("")
setCategoryFilter("all")
setActiveTab("all")
}}
>
Clear All Filters
</Button>
</div>
)}
{/* Floating batch submission button for mobile */}
{batchMode && selectedCount > 0 && !isSubmittingBatch && (
<div className="fixed bottom-4 right-4 md:hidden">
<Button size="lg" onClick={startBatchSubmission} className="shadow-lg">
Submit Votes ({selectedCount})
</Button>
</div>
)}
{/* Flagging Dialog */}
<Dialog open={flaggingInsight !== null} onOpenChange={(open) => !open && setFlaggingInsight(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Flag Insight</DialogTitle>
<DialogDescription>
Report this insight for review by community moderators.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label>Reason for flagging</Label>
<div className="grid grid-cols-1 gap-2">
<Button
type="button"
variant={flagReason === "Spam" ? "default" : "outline"}
className={flagReason === "Spam" ? "" : "border-muted-foreground/20"}
onClick={() => setFlagReason("Spam")}
>
Spam
</Button>
<Button
type="button"
variant={flagReason === "Inappropriate" ? "default" : "outline"}
className={flagReason === "Inappropriate" ? "" : "border-muted-foreground/20"}
onClick={() => setFlagReason("Inappropriate")}
>
Inappropriate Content
</Button>
<Button
type="button"
variant={flagReason === "Other" ? "default" : "outline"}
className={flagReason === "Other" ? "" : "border-muted-foreground/20"}
onClick={() => setFlagReason("Other")}
>
Other
</Button>
</div>
</div>
{flagReason === "Other" && (
<div className="space-y-2">
<Label htmlFor="flag-details">Additional details (optional)</Label>
<Textarea
id="flag-details"
value={flagDetails}
onChange={(e) => setFlagDetails(e.target.value)}
placeholder="Please provide any additional context..."
className="min-h-[100px]"
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFlaggingInsight(null)}>
Cancel
</Button>
<Button
disabled={!flagReason || isFlagging}
onClick={handleFlagInsight}
className="gap-2"
>
{isFlagging ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Submitting...
</>
) : (
"Submit Flag"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}