diff --git a/src/components/moderation/QueueItem.tsx b/src/components/moderation/QueueItem.tsx index eb6b0bb8..233e146b 100644 --- a/src/components/moderation/QueueItem.tsx +++ b/src/components/moderation/QueueItem.tsx @@ -11,6 +11,7 @@ import { normalizePhotoData } from '@/lib/photoHelpers'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { AlertTriangle } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { SubmissionItemsList } from './SubmissionItemsList'; import { getSubmissionTypeLabel } from '@/lib/moderation/entities'; import { QueueItemHeader } from './renderers/QueueItemHeader'; @@ -21,6 +22,7 @@ import { QueueItemContext } from './renderers/QueueItemContext'; import { QueueItemActions } from './renderers/QueueItemActions'; import { SubmissionMetadataPanel } from './SubmissionMetadataPanel'; import { AuditTrailViewer } from './AuditTrailViewer'; +import { RawDataViewer } from './RawDataViewer'; interface QueueItemProps { item: ModerationItem; @@ -76,6 +78,7 @@ export const QueueItem = memo(({ }: QueueItemProps) => { const [validationResult, setValidationResult] = useState(null); const [isClaiming, setIsClaiming] = useState(false); + const [showRawData, setShowRawData] = useState(false); // Fetch relational photo data for photo submissions const { photos: photoItems, loading: photosLoading } = usePhotoSubmissionItems( @@ -141,6 +144,7 @@ export const QueueItem = memo(({ currentLockSubmissionId={currentLockSubmissionId} validationResult={validationResult} onValidationChange={handleValidationChange} + onViewRawData={() => setShowRawData(true)} /> @@ -343,6 +347,19 @@ export const QueueItem = memo(({ onClaim={handleClaim} /> + + {/* Raw Data Modal */} + + + + Technical Details - Complete Submission Data + + + + ); }, (prevProps, nextProps) => { diff --git a/src/components/moderation/RawDataViewer.tsx b/src/components/moderation/RawDataViewer.tsx new file mode 100644 index 00000000..906f84be --- /dev/null +++ b/src/components/moderation/RawDataViewer.tsx @@ -0,0 +1,224 @@ +import { useState, useMemo } from 'react'; +import { Copy, Download, ChevronRight, ChevronDown, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Input } from '@/components/ui/input'; +import { toast } from 'sonner'; + +interface RawDataViewerProps { + data: any; + title?: string; +} + +export function RawDataViewer({ data, title = 'Raw Data' }: RawDataViewerProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [expandedPaths, setExpandedPaths] = useState>(new Set(['root'])); + const [copiedPath, setCopiedPath] = useState(null); + + const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(jsonString); + toast.success('Copied to clipboard'); + } catch (error) { + toast.error('Failed to copy'); + } + }; + + const handleDownload = () => { + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${title.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success('Download started'); + }; + + const togglePath = (path: string) => { + const newExpanded = new Set(expandedPaths); + if (newExpanded.has(path)) { + newExpanded.delete(path); + } else { + newExpanded.add(path); + } + setExpandedPaths(newExpanded); + }; + + const handleCopyValue = async (value: any, path: string) => { + try { + const valueString = typeof value === 'string' ? value : JSON.stringify(value, null, 2); + await navigator.clipboard.writeText(valueString); + setCopiedPath(path); + setTimeout(() => setCopiedPath(null), 2000); + toast.success('Value copied'); + } catch (error) { + toast.error('Failed to copy'); + } + }; + + const renderValue = (value: any, key: string, path: string, depth: number = 0): JSX.Element => { + const isExpanded = expandedPaths.has(path); + const indent = depth * 20; + + // Filter by search query + if (searchQuery && !JSON.stringify({ [key]: value }).toLowerCase().includes(searchQuery.toLowerCase())) { + return <>; + } + + if (value === null) { + return ( +
+ {key}: + null +
+ ); + } + + if (typeof value === 'boolean') { + return ( +
+ {key}: + + {value.toString()} + +
+ ); + } + + if (typeof value === 'number') { + return ( +
+ {key}: + {value} + +
+ ); + } + + if (typeof value === 'string') { + const isUrl = value.startsWith('http://') || value.startsWith('https://'); + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); + const isDate = !isNaN(Date.parse(value)) && value.includes('-'); + + return ( +
+ {key}: + {isUrl ? ( + + "{value}" + + ) : ( + + "{value}" + + )} + +
+ ); + } + + if (Array.isArray(value)) { + return ( +
+
togglePath(path)} + > + {isExpanded ? : } + {key}: + Array[{value.length}] +
+ {isExpanded && ( +
+ {value.map((item, index) => renderValue(item, `[${index}]`, `${path}.${index}`, depth + 1))} +
+ )} +
+ ); + } + + if (typeof value === 'object') { + const keys = Object.keys(value); + return ( +
+
togglePath(path)} + > + {isExpanded ? : } + {key}: + Object ({keys.length} keys) +
+ {isExpanded && ( +
+ {keys.map((k) => renderValue(value[k], k, `${path}.${k}`, depth + 1))} +
+ )} +
+ ); + } + + return <>; + }; + + return ( +
+ {/* Header */} +
+

{title}

+
+ + +
+
+ + {/* Search */} + setSearchQuery(e.target.value)} + className="max-w-sm" + /> + + {/* JSON Tree */} + +
+ {Object.keys(data).map((key) => renderValue(data[key], key, `root.${key}`, 0))} +
+
+ + {/* Stats */} +
+ Keys: {Object.keys(data).length} + Size: {(jsonString.length / 1024).toFixed(2)} KB + Lines: {jsonString.split('\n').length} +
+
+ ); +} diff --git a/src/components/moderation/renderers/QueueItemHeader.tsx b/src/components/moderation/renderers/QueueItemHeader.tsx index 7af7e12c..b47cd894 100644 --- a/src/components/moderation/renderers/QueueItemHeader.tsx +++ b/src/components/moderation/renderers/QueueItemHeader.tsx @@ -1,6 +1,7 @@ import { memo, useCallback } from 'react'; -import { MessageSquare, Image, FileText, Calendar, Edit, Lock, AlertCircle } from 'lucide-react'; +import { MessageSquare, Image, FileText, Calendar, Edit, Lock, AlertCircle, Code2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/ui/user-avatar'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ValidationSummary } from '../ValidationSummary'; @@ -16,6 +17,7 @@ interface QueueItemHeaderProps { currentLockSubmissionId?: string; validationResult: ValidationResult | null; onValidationChange: (result: ValidationResult) => void; + onViewRawData?: () => void; } const getStatusBadgeVariant = (status: string): "default" | "secondary" | "destructive" | "outline" => { @@ -36,7 +38,8 @@ export const QueueItemHeader = memo(({ isLockedByOther, currentLockSubmissionId, validationResult, - onValidationChange + onValidationChange, + onViewRawData }: QueueItemHeaderProps) => { const handleValidationChange = useCallback((result: ValidationResult) => { onValidationChange(result); @@ -45,7 +48,7 @@ export const QueueItemHeader = memo(({ return ( <>
-
+
{item.type === 'review' ? ( <> @@ -114,18 +117,40 @@ export const QueueItemHeader = memo(({ /> )}
- - -
- - {format(new Date(item.created_at), isMobile ? 'MMM d, HH:mm:ss' : 'MMM d, yyyy HH:mm:ss.SSS')} -
-
- -

Full timestamp:

-

{item.created_at}

-
-
+ +
+ {onViewRawData && ( + + + + + +

View complete JSON data

+
+
+ )} + + + +
+ + {format(new Date(item.created_at), isMobile ? 'MMM d, HH:mm:ss' : 'MMM d, yyyy HH:mm:ss.SSS')} +
+
+ +

Full timestamp:

+

{item.created_at}

+
+
+
{item.user_profile && (