mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add message reporting and account deletion
- Add user message reporting via long press menu - Add delete-account edge function for App Store compliance - Update admin moderation page with AI/User tabs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
314745c6cb
commit
693ae59321
14 files changed
+1583
-211
No files matched your search
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { CheckCircle, Eye, RefreshCw, MessageSquare, Sparkles } from 'lucide-react';
|
||||
import { CheckCircle, Eye, RefreshCw, MessageSquare, Sparkles, Flag, AlertTriangle, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
import { describeImage } from '@/lib/ai/analyzers/media';
|
||||
@@ -30,21 +31,59 @@ interface FlaggedMessage {
|
||||
media_type?: string;
|
||||
}
|
||||
|
||||
interface UserReport {
|
||||
id: string;
|
||||
message_id: string;
|
||||
reporter_id: string;
|
||||
reason: string;
|
||||
status: 'pending' | 'reviewed' | 'dismissed';
|
||||
created_at: string;
|
||||
message: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
match_id: string | null;
|
||||
content: string | null;
|
||||
media_path: string | null;
|
||||
media_type: string | null;
|
||||
} | null;
|
||||
reporter_profile: {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
harassment: 'Harassment',
|
||||
spam: 'Spam',
|
||||
inappropriate_content: 'Inappropriate Content',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
export function FlaggedMessagesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [messages, setMessages] = useState<FlaggedMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// AI Flagged state
|
||||
const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]);
|
||||
const [aiLoading, setAiLoading] = useState(true);
|
||||
|
||||
// User Reports state
|
||||
const [userReports, setUserReports] = useState<UserReport[]>([]);
|
||||
const [reportsLoading, setReportsLoading] = useState(true);
|
||||
|
||||
// Shared state
|
||||
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
|
||||
const [selectedReport, setSelectedReport] = useState<UserReport | null>(null);
|
||||
const [decryptedContent, setDecryptedContent] = useState<{ text: string | null; mediaUrl: string | null }>({ text: null, mediaUrl: null });
|
||||
const [decrypting, setDecrypting] = useState(false);
|
||||
const [processing, setProcessing] = useState<string | null>(null); // messageId being processed
|
||||
const [processing, setProcessing] = useState<string | null>(null);
|
||||
const [imageDescription, setImageDescription] = useState<string | null>(null);
|
||||
const [describing, setDescribing] = useState(false);
|
||||
const [inlineDescriptions, setInlineDescriptions] = useState<Record<string, string>>({});
|
||||
const [describingInline, setDescribingInline] = useState<string | null>(null);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
setLoading(true);
|
||||
// Fetch AI flagged messages
|
||||
const fetchAiMessages = async () => {
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
@@ -53,19 +92,49 @@ export function FlaggedMessagesPage() {
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setMessages(data || []);
|
||||
setAiMessages(data || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load flagged messages:', error);
|
||||
toast.error('Failed to load messages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Fetch user reports
|
||||
const fetchUserReports = async () => {
|
||||
setReportsLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('message_reports')
|
||||
.select(`
|
||||
id,
|
||||
message_id,
|
||||
reporter_id,
|
||||
reason,
|
||||
status,
|
||||
created_at,
|
||||
message:messages(id, user_id, match_id, content, media_path, media_type),
|
||||
reporter_profile:profiles!reporter_id(name, email)
|
||||
`)
|
||||
.eq('status', 'pending')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setUserReports((data || []) as unknown as UserReport[]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user reports:', error);
|
||||
toast.error('Failed to load reports');
|
||||
} finally {
|
||||
setReportsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
fetchAiMessages();
|
||||
fetchUserReports();
|
||||
}, []);
|
||||
|
||||
|
||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
||||
const fetchDecryptedMedia = async (messageId: string) => {
|
||||
try {
|
||||
@@ -82,7 +151,7 @@ export function FlaggedMessagesPage() {
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch media');
|
||||
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
@@ -90,27 +159,61 @@ export function FlaggedMessagesPage() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = async (message: FlaggedMessage) => {
|
||||
|
||||
const handleViewAiMessage = async (message: FlaggedMessage) => {
|
||||
setSelectedMessage(message);
|
||||
setSelectedReport(null);
|
||||
setDecrypting(true);
|
||||
setDecryptedContent({ text: null, mediaUrl: null });
|
||||
setImageDescription(null);
|
||||
|
||||
|
||||
try {
|
||||
// Text
|
||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id }
|
||||
});
|
||||
|
||||
|
||||
let mediaUrl = null;
|
||||
if (message.media_path) {
|
||||
mediaUrl = await fetchDecryptedMedia(message.id);
|
||||
}
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
mediaUrl
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
mediaUrl
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
toast.error("Error loading content");
|
||||
} finally {
|
||||
setDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewReport = async (report: UserReport) => {
|
||||
if (!report.message) {
|
||||
toast.error('Message no longer exists');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedReport(report);
|
||||
setSelectedMessage(null);
|
||||
setDecrypting(true);
|
||||
setDecryptedContent({ text: null, mediaUrl: null });
|
||||
setImageDescription(null);
|
||||
|
||||
try {
|
||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: report.message.id }
|
||||
});
|
||||
|
||||
let mediaUrl = null;
|
||||
if (report.message.media_path) {
|
||||
mediaUrl = await fetchDecryptedMedia(report.message.id);
|
||||
}
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
mediaUrl
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
@@ -140,7 +243,6 @@ export function FlaggedMessagesPage() {
|
||||
|
||||
setDescribingInline(message.id);
|
||||
try {
|
||||
// First fetch the decrypted media as blob
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
toast.error('Not authenticated');
|
||||
@@ -161,13 +263,11 @@ export function FlaggedMessagesPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert blob to base64
|
||||
const blob = await response.blob();
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
// Extract base64 part from data URL
|
||||
const base64Data = result.split(',')[1];
|
||||
resolve(base64Data);
|
||||
};
|
||||
@@ -175,7 +275,6 @@ export function FlaggedMessagesPage() {
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
// Then describe it
|
||||
const description = await describeImage(base64);
|
||||
setInlineDescriptions(prev => ({ ...prev, [message.id]: description }));
|
||||
} catch (error) {
|
||||
@@ -195,9 +294,9 @@ export function FlaggedMessagesPage() {
|
||||
.eq('id', messageId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
|
||||
toast.success('Message marked as safe');
|
||||
setMessages(prev => prev.filter(m => m.id !== messageId));
|
||||
setAiMessages(prev => prev.filter(m => m.id !== messageId));
|
||||
if (selectedMessage?.id === messageId) setSelectedMessage(null);
|
||||
} catch (error) {
|
||||
toast.error('Failed to update status');
|
||||
@@ -206,132 +305,293 @@ export function FlaggedMessagesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissReport = async (reportId: string) => {
|
||||
setProcessing(reportId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('message_reports')
|
||||
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
|
||||
.eq('id', reportId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Report dismissed');
|
||||
setUserReports(prev => prev.filter(r => r.id !== reportId));
|
||||
if (selectedReport?.id === reportId) setSelectedReport(null);
|
||||
} catch (error) {
|
||||
toast.error('Failed to dismiss report');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewReport = async (reportId: string) => {
|
||||
setProcessing(reportId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('message_reports')
|
||||
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
|
||||
.eq('id', reportId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Report marked as reviewed');
|
||||
setUserReports(prev => prev.filter(r => r.id !== reportId));
|
||||
if (selectedReport?.id === reportId) setSelectedReport(null);
|
||||
} catch (error) {
|
||||
toast.error('Failed to update report');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpToChat = () => {
|
||||
if (!selectedMessage || !selectedMessage.match_id) {
|
||||
const matchId = selectedMessage?.match_id || selectedReport?.message?.match_id;
|
||||
const userId = selectedMessage?.user_id || selectedReport?.message?.user_id;
|
||||
|
||||
if (!matchId) {
|
||||
toast.error("No match context available");
|
||||
return;
|
||||
}
|
||||
navigate(`/users/${selectedMessage.user_id}/matches/${selectedMessage.match_id}`);
|
||||
navigate(`/users/${userId}/matches/${matchId}`);
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
setSelectedMessage(null);
|
||||
setSelectedReport(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Flagged Messages</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Content Moderation</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review content flagged by AI moderation
|
||||
Review AI-flagged content and user reports
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchMessages} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{messages.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<CheckCircle className="h-8 w-8 text-green-500/50" />
|
||||
<p>No flagged messages found. Good job!</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<React.Fragment key={message.id}>
|
||||
<Tabs defaultValue="ai-flagged" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="ai-flagged" className="gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
AI Flagged
|
||||
{aiMessages.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">{aiMessages.length}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="user-reports" className="gap-2">
|
||||
<Flag className="h-4 w-4" />
|
||||
User Reports
|
||||
{userReports.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">{userReports.length}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ai-flagged" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={fetchAiMessages} disabled={aiLoading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${aiLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{format(new Date(message.created_at), 'MMM d, HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="destructive" className="bg-red-500/10 text-red-500 hover:bg-red-500/20 border-0">
|
||||
{message.flag_reason || 'Unknown'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{message.media_path ? (
|
||||
<Badge variant="outline">{message.media_type || 'Media'}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Text</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleView(message)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Review
|
||||
</Button>
|
||||
{message.media_path && message.media_type !== 'video' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDescribeInline(message)}
|
||||
disabled={describingInline === message.id}
|
||||
>
|
||||
{describingInline === message.id ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Describe
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={() => handleMarkSafe(message.id)}
|
||||
disabled={processing === message.id}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark Safe
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
{inlineDescriptions[message.id] && (
|
||||
<TableRow className="bg-blue-50/50 dark:bg-blue-900/10">
|
||||
<TableCell colSpan={4} className="py-2">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<Sparkles className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
{inlineDescriptions[message.id]}
|
||||
</p>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{aiMessages.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<CheckCircle className="h-8 w-8 text-green-500/50" />
|
||||
<p>No AI-flagged messages found. Good job!</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
aiMessages.map((message) => (
|
||||
<React.Fragment key={message.id}>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{format(new Date(message.created_at), 'MMM d, HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="destructive" className="bg-red-500/10 text-red-500 hover:bg-red-500/20 border-0">
|
||||
{message.flag_reason || 'Unknown'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{message.media_path ? (
|
||||
<Badge variant="outline">{message.media_type || 'Media'}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Text</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewAiMessage(message)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Review
|
||||
</Button>
|
||||
{message.media_path && message.media_type !== 'video' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDescribeInline(message)}
|
||||
disabled={describingInline === message.id}
|
||||
>
|
||||
{describingInline === message.id ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Describe
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={() => handleMarkSafe(message.id)}
|
||||
disabled={processing === message.id}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark Safe
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{inlineDescriptions[message.id] && (
|
||||
<TableRow className="bg-blue-50/50 dark:bg-blue-900/10">
|
||||
<TableCell colSpan={4} className="py-2">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<Sparkles className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
{inlineDescriptions[message.id]}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<Dialog open={!!selectedMessage} onOpenChange={(open) => !open && setSelectedMessage(null)}>
|
||||
<TabsContent value="user-reports" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={fetchUserReports} disabled={reportsLoading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${reportsLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Reporter</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{userReports.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<CheckCircle className="h-8 w-8 text-green-500/50" />
|
||||
<p>No pending user reports. All clear!</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
userReports.map((report) => (
|
||||
<TableRow key={report.id}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{format(new Date(report.created_at), 'MMM d, HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{report.reporter_profile?.name || report.reporter_profile?.email || 'Unknown'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="bg-orange-500/10 text-orange-600 border-orange-200">
|
||||
{REASON_LABELS[report.reason] || report.reason}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{report.message?.media_path ? (
|
||||
<Badge variant="outline">{report.message.media_type || 'Media'}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Text</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewReport(report)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
onClick={() => handleDismissReport(report.id)}
|
||||
disabled={processing === report.id}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={() => handleReviewReport(report.id)}
|
||||
disabled={processing === report.id}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark Reviewed
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Review Dialog - AI Flagged Messages */}
|
||||
<Dialog open={!!selectedMessage} onOpenChange={(open) => !open && closeDialogs()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Content</DialogTitle>
|
||||
<DialogTitle>Review AI Flagged Content</DialogTitle>
|
||||
<DialogDescription>
|
||||
Decrypted content for review. Flagged for: {selectedMessage?.flag_reason}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="min-h-[200px] bg-muted/30 rounded-md p-4 flex flex-col gap-4">
|
||||
{decrypting ? (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground py-8">
|
||||
@@ -348,15 +608,15 @@ export function FlaggedMessagesPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{decryptedContent.mediaUrl && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Media Content</h4>
|
||||
{selectedMessage?.media_type !== 'video' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleDescribeImage}
|
||||
disabled={describing}
|
||||
>
|
||||
@@ -369,7 +629,7 @@ export function FlaggedMessagesPage() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-center bg-black/5 rounded-lg overflow-hidden border">
|
||||
{selectedMessage?.media_type === 'video' ? (
|
||||
<video src={decryptedContent.mediaUrl} controls className="max-h-[400px] w-full object-contain" />
|
||||
@@ -405,8 +665,8 @@ export function FlaggedMessagesPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => setSelectedMessage(null)}>Cancel</Button>
|
||||
<Button
|
||||
<Button variant="ghost" onClick={closeDialogs}>Cancel</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => selectedMessage && handleMarkSafe(selectedMessage.id)}
|
||||
>
|
||||
@@ -416,7 +676,110 @@ export function FlaggedMessagesPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Review Dialog - User Reports */}
|
||||
<Dialog open={!!selectedReport} onOpenChange={(open) => !open && closeDialogs()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review User Report</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reported by: {selectedReport?.reporter_profile?.name || selectedReport?.reporter_profile?.email || 'Unknown'}
|
||||
<br />
|
||||
Reason: {selectedReport?.reason ? (REASON_LABELS[selectedReport.reason] || selectedReport.reason) : 'Unknown'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-[200px] bg-muted/30 rounded-md p-4 flex flex-col gap-4">
|
||||
{decrypting ? (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<p className="text-sm">Decrypting secure content...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-4">
|
||||
{decryptedContent.text && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Message Text</h4>
|
||||
<div className="bg-background border rounded p-3 text-sm whitespace-pre-wrap">
|
||||
{decryptedContent.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{decryptedContent.mediaUrl && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Media Content</h4>
|
||||
{selectedReport?.message?.media_type !== 'video' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleDescribeImage}
|
||||
disabled={describing}
|
||||
>
|
||||
{describing ? (
|
||||
<RefreshCw className="h-3 w-3 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3 mr-2" />
|
||||
)}
|
||||
Describe Image
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center bg-black/5 rounded-lg overflow-hidden border">
|
||||
{selectedReport?.message?.media_type === 'video' ? (
|
||||
<video src={decryptedContent.mediaUrl} controls className="max-h-[400px] w-full object-contain" />
|
||||
) : (
|
||||
<img src={decryptedContent.mediaUrl} alt="Reported content" className="max-h-[400px] w-full object-contain" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{imageDescription && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||
<h5 className="text-xs font-semibold text-blue-700 dark:text-blue-300 mb-1 flex items-center">
|
||||
<Sparkles className="h-3 w-3 mr-1" />
|
||||
AI Description
|
||||
</h5>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">{imageDescription}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!decryptedContent.text && !decryptedContent.mediaUrl && (
|
||||
<p className="text-center text-muted-foreground py-8">No content displayed</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleJumpToChat}>
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Jump to Chat
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={closeDialogs}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
onClick={() => selectedReport && handleDismissReport(selectedReport.id)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => selectedReport && handleReviewReport(selectedReport.id)}
|
||||
>
|
||||
Mark Reviewed
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user