mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 12:51:14 -05:00
225 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|