Files
thrilltrack-explorer/src-old/components/moderation/RawDataViewer.tsx

225 lines
8.2 KiB
TypeScript

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