1281 lines
44 KiB
TypeScript
1281 lines
44 KiB
TypeScript
![]() |
"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>
|
||
|
)
|
||
|
}
|
||
|
|