mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:51:12 -05:00
Refactor: Implement Phase 2
This commit is contained in:
@@ -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<ValidationResult | null>(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)}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
@@ -343,6 +347,19 @@ export const QueueItem = memo(({
|
||||
onClaim={handleClaim}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
{/* Raw Data Modal */}
|
||||
<Dialog open={showRawData} onOpenChange={setShowRawData}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Technical Details - Complete Submission Data</DialogTitle>
|
||||
</DialogHeader>
|
||||
<RawDataViewer
|
||||
data={item}
|
||||
title={`Submission ${item.id.slice(0, 8)}`}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
|
||||
224
src/components/moderation/RawDataViewer.tsx
Normal file
224
src/components/moderation/RawDataViewer.tsx
Normal file
@@ -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<Set<string>>(new Set(['root']));
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(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 (
|
||||
<div className="flex items-center gap-2 py-1" style={{ paddingLeft: `${indent}px` }}>
|
||||
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
|
||||
<span className="text-sm font-mono text-muted-foreground italic">null</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1" style={{ paddingLeft: `${indent}px` }}>
|
||||
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
|
||||
<span className={`text-sm font-mono ${value ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{value.toString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1" style={{ paddingLeft: `${indent}px` }}>
|
||||
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
|
||||
<span className="text-sm font-mono text-purple-600 dark:text-purple-400">{value}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => handleCopyValue(value, path)}
|
||||
>
|
||||
{copiedPath === path ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-2 py-1 group" style={{ paddingLeft: `${indent}px` }}>
|
||||
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
|
||||
{isUrl ? (
|
||||
<a href={value} target="_blank" rel="noopener noreferrer" className="text-sm font-mono text-blue-600 dark:text-blue-400 hover:underline">
|
||||
"{value}"
|
||||
</a>
|
||||
) : (
|
||||
<span className={`text-sm font-mono ${isUuid ? 'text-orange-600 dark:text-orange-400' : isDate ? 'text-cyan-600 dark:text-cyan-400' : 'text-green-600 dark:text-green-400'}`}>
|
||||
"{value}"
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => handleCopyValue(value, path)}
|
||||
>
|
||||
{copiedPath === path ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div className="py-1" style={{ paddingLeft: `${indent}px` }}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 rounded px-2 py-1 -ml-2"
|
||||
onClick={() => togglePath(path)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
|
||||
<Badge variant="outline" className="text-xs">Array[{value.length}]</Badge>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="ml-4 border-l border-muted-foreground/20 pl-2">
|
||||
{value.map((item, index) => renderValue(item, `[${index}]`, `${path}.${index}`, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const keys = Object.keys(value);
|
||||
return (
|
||||
<div className="py-1" style={{ paddingLeft: `${indent}px` }}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 rounded px-2 py-1 -ml-2"
|
||||
onClick={() => togglePath(path)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
|
||||
<Badge variant="outline" className="text-xs">Object ({keys.length} keys)</Badge>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="ml-4 border-l border-muted-foreground/20 pl-2">
|
||||
{keys.map((k) => renderValue(value[k], k, `${path}.${k}`, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Input
|
||||
placeholder="Search in JSON..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
|
||||
{/* JSON Tree */}
|
||||
<ScrollArea className="h-[600px] w-full rounded-md border bg-muted/30 p-4">
|
||||
<div className="font-mono text-sm">
|
||||
{Object.keys(data).map((key) => renderValue(data[key], key, `root.${key}`, 0))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>Keys: {Object.keys(data).length}</span>
|
||||
<span>Size: {(jsonString.length / 1024).toFixed(2)} KB</span>
|
||||
<span>Lines: {jsonString.split('\n').length}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className={`flex gap-3 ${isMobile ? 'flex-col' : 'items-center justify-between'}`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap flex-1">
|
||||
<Badge variant={getStatusBadgeVariant(item.status)} className={isMobile ? "text-xs" : ""}>
|
||||
{item.type === 'review' ? (
|
||||
<>
|
||||
@@ -114,18 +117,40 @@ export const QueueItemHeader = memo(({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={`flex items-center gap-2 text-muted-foreground ${isMobile ? 'text-xs' : 'text-sm'}`}>
|
||||
<Calendar className={isMobile ? "w-3 h-3" : "w-4 h-4"} />
|
||||
{format(new Date(item.created_at), isMobile ? 'MMM d, HH:mm:ss' : 'MMM d, yyyy HH:mm:ss.SSS')}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">Full timestamp:</p>
|
||||
<p className="font-mono">{item.created_at}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onViewRawData && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onViewRawData}
|
||||
className="h-8"
|
||||
>
|
||||
<Code2 className="h-4 w-4" />
|
||||
{!isMobile && <span className="ml-2">Raw Data</span>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>View complete JSON data</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={`flex items-center gap-2 text-muted-foreground ${isMobile ? 'text-xs' : 'text-sm'}`}>
|
||||
<Calendar className={isMobile ? "w-3 h-3" : "w-4 h-4"} />
|
||||
{format(new Date(item.created_at), isMobile ? 'MMM d, HH:mm:ss' : 'MMM d, yyyy HH:mm:ss.SSS')}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">Full timestamp:</p>
|
||||
<p className="font-mono">{item.created_at}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.user_profile && (
|
||||
|
||||
Reference in New Issue
Block a user