Refactor: Implement Phase 2

This commit is contained in:
gpt-engineer-app[bot]
2025-11-04 01:50:11 +00:00
parent 9b1964d634
commit 91da509f04
3 changed files with 281 additions and 15 deletions

View File

@@ -11,6 +11,7 @@ import { normalizePhotoData } from '@/lib/photoHelpers';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { SubmissionItemsList } from './SubmissionItemsList'; import { SubmissionItemsList } from './SubmissionItemsList';
import { getSubmissionTypeLabel } from '@/lib/moderation/entities'; import { getSubmissionTypeLabel } from '@/lib/moderation/entities';
import { QueueItemHeader } from './renderers/QueueItemHeader'; import { QueueItemHeader } from './renderers/QueueItemHeader';
@@ -21,6 +22,7 @@ import { QueueItemContext } from './renderers/QueueItemContext';
import { QueueItemActions } from './renderers/QueueItemActions'; import { QueueItemActions } from './renderers/QueueItemActions';
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel'; import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
import { AuditTrailViewer } from './AuditTrailViewer'; import { AuditTrailViewer } from './AuditTrailViewer';
import { RawDataViewer } from './RawDataViewer';
interface QueueItemProps { interface QueueItemProps {
item: ModerationItem; item: ModerationItem;
@@ -76,6 +78,7 @@ export const QueueItem = memo(({
}: QueueItemProps) => { }: QueueItemProps) => {
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null); const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isClaiming, setIsClaiming] = useState(false); const [isClaiming, setIsClaiming] = useState(false);
const [showRawData, setShowRawData] = useState(false);
// Fetch relational photo data for photo submissions // Fetch relational photo data for photo submissions
const { photos: photoItems, loading: photosLoading } = usePhotoSubmissionItems( const { photos: photoItems, loading: photosLoading } = usePhotoSubmissionItems(
@@ -141,6 +144,7 @@ export const QueueItem = memo(({
currentLockSubmissionId={currentLockSubmissionId} currentLockSubmissionId={currentLockSubmissionId}
validationResult={validationResult} validationResult={validationResult}
onValidationChange={handleValidationChange} onValidationChange={handleValidationChange}
onViewRawData={() => setShowRawData(true)}
/> />
</CardHeader> </CardHeader>
@@ -343,6 +347,19 @@ export const QueueItem = memo(({
onClaim={handleClaim} onClaim={handleClaim}
/> />
</CardContent> </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> </Card>
); );
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {

View 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>
);
}

View File

@@ -1,6 +1,7 @@
import { memo, useCallback } from 'react'; 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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { UserAvatar } from '@/components/ui/user-avatar'; import { UserAvatar } from '@/components/ui/user-avatar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ValidationSummary } from '../ValidationSummary'; import { ValidationSummary } from '../ValidationSummary';
@@ -16,6 +17,7 @@ interface QueueItemHeaderProps {
currentLockSubmissionId?: string; currentLockSubmissionId?: string;
validationResult: ValidationResult | null; validationResult: ValidationResult | null;
onValidationChange: (result: ValidationResult) => void; onValidationChange: (result: ValidationResult) => void;
onViewRawData?: () => void;
} }
const getStatusBadgeVariant = (status: string): "default" | "secondary" | "destructive" | "outline" => { const getStatusBadgeVariant = (status: string): "default" | "secondary" | "destructive" | "outline" => {
@@ -36,7 +38,8 @@ export const QueueItemHeader = memo(({
isLockedByOther, isLockedByOther,
currentLockSubmissionId, currentLockSubmissionId,
validationResult, validationResult,
onValidationChange onValidationChange,
onViewRawData
}: QueueItemHeaderProps) => { }: QueueItemHeaderProps) => {
const handleValidationChange = useCallback((result: ValidationResult) => { const handleValidationChange = useCallback((result: ValidationResult) => {
onValidationChange(result); onValidationChange(result);
@@ -45,7 +48,7 @@ export const QueueItemHeader = memo(({
return ( return (
<> <>
<div className={`flex gap-3 ${isMobile ? 'flex-col' : 'items-center justify-between'}`}> <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" : ""}> <Badge variant={getStatusBadgeVariant(item.status)} className={isMobile ? "text-xs" : ""}>
{item.type === 'review' ? ( {item.type === 'review' ? (
<> <>
@@ -114,18 +117,40 @@ export const QueueItemHeader = memo(({
/> />
)} )}
</div> </div>
<Tooltip>
<TooltipTrigger asChild> <div className="flex items-center gap-2">
<div className={`flex items-center gap-2 text-muted-foreground ${isMobile ? 'text-xs' : 'text-sm'}`}> {onViewRawData && (
<Calendar className={isMobile ? "w-3 h-3" : "w-4 h-4"} /> <Tooltip>
{format(new Date(item.created_at), isMobile ? 'MMM d, HH:mm:ss' : 'MMM d, yyyy HH:mm:ss.SSS')} <TooltipTrigger asChild>
</div> <Button
</TooltipTrigger> variant="ghost"
<TooltipContent> size="sm"
<p className="text-xs">Full timestamp:</p> onClick={onViewRawData}
<p className="font-mono">{item.created_at}</p> className="h-8"
</TooltipContent> >
</Tooltip> <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> </div>
{item.user_profile && ( {item.user_profile && (