- Task #7: Create CopyToolbar and copy utilities (copy-utils.ts with text/JSON/CSV formatting, clipboard API with fallback) (CopyToolbar with copy all/selected, format dropdown, download export) - Task #8: Create LogEntry row component (click-to-copy with visual feedback, expandable metrics view) (level/category badges, search highlighting, shift+click selection) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
308 lines
9.7 KiB
TypeScript
308 lines
9.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { Copy, Check, Download, ChevronDown } from 'lucide-react';
|
|
import { StructuredLog } from './index';
|
|
import {
|
|
ExportFormat,
|
|
copyToClipboard,
|
|
formatLogsForExport,
|
|
downloadAsFile,
|
|
getExportFileInfo,
|
|
} from '@/lib/copy-utils';
|
|
|
|
export interface CopyToolbarProps {
|
|
logs: StructuredLog[];
|
|
selectedIndices?: Set<number>;
|
|
onCopySuccess?: () => void;
|
|
}
|
|
|
|
const FORMAT_OPTIONS: { value: ExportFormat; label: string }[] = [
|
|
{ value: 'text', label: 'Text' },
|
|
{ value: 'json', label: 'JSON' },
|
|
{ value: 'csv', label: 'CSV' },
|
|
];
|
|
|
|
/**
|
|
* Toast notification component for copy success feedback
|
|
*/
|
|
function Toast({
|
|
message,
|
|
isVisible,
|
|
onClose,
|
|
}: {
|
|
message: string;
|
|
isVisible: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
useEffect(() => {
|
|
if (isVisible) {
|
|
const timer = setTimeout(onClose, 2500);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isVisible, onClose]);
|
|
|
|
if (!isVisible) return null;
|
|
|
|
return (
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 animate-fade-in">
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white text-sm font-medium rounded-lg shadow-lg whitespace-nowrap">
|
|
<Check className="w-4 h-4" />
|
|
{message}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Format dropdown component
|
|
*/
|
|
function FormatDropdown({
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
value: ExportFormat;
|
|
onChange: (format: ExportFormat) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
if (isOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const selectedOption = FORMAT_OPTIONS.find((opt) => opt.value === value);
|
|
|
|
return (
|
|
<div ref={dropdownRef} className="relative">
|
|
<button
|
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
disabled={disabled}
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border transition-colors ${
|
|
disabled
|
|
? 'bg-gray-800 text-gray-500 border-gray-700 cursor-not-allowed'
|
|
: 'bg-gray-700 text-gray-300 border-gray-600 hover:bg-gray-600 hover:text-gray-200'
|
|
}`}
|
|
>
|
|
<span>{selectedOption?.label || 'Text'}</span>
|
|
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="absolute top-full left-0 mt-1 z-20 bg-gray-800 border border-gray-600 rounded-lg shadow-lg py-1 min-w-[80px]">
|
|
{FORMAT_OPTIONS.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
onClick={() => {
|
|
onChange(option.value);
|
|
setIsOpen(false);
|
|
}}
|
|
className={`w-full px-3 py-1.5 text-xs text-left transition-colors ${
|
|
value === option.value
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-gray-300 hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* CopyToolbar - Toolbar for copying and exporting logs
|
|
*
|
|
* Features:
|
|
* - Copy All button to copy all logs
|
|
* - Copy Selected button to copy only selected logs
|
|
* - Format dropdown to choose Text, JSON, or CSV
|
|
* - Download button for exporting as file
|
|
* - Toast notification on copy success
|
|
*/
|
|
export default function CopyToolbar({
|
|
logs,
|
|
selectedIndices = new Set(),
|
|
onCopySuccess,
|
|
}: CopyToolbarProps) {
|
|
const [format, setFormat] = useState<ExportFormat>('text');
|
|
const [toastMessage, setToastMessage] = useState<string>('');
|
|
const [showToast, setShowToast] = useState(false);
|
|
const [copyingAll, setCopyingAll] = useState(false);
|
|
const [copyingSelected, setCopyingSelected] = useState(false);
|
|
|
|
const hasSelection = selectedIndices.size > 0;
|
|
const hasLogs = logs.length > 0;
|
|
|
|
// Get selected logs from indices
|
|
const getSelectedLogs = useCallback((): StructuredLog[] => {
|
|
return logs.filter((_, index) => selectedIndices.has(index));
|
|
}, [logs, selectedIndices]);
|
|
|
|
// Show toast notification
|
|
const showToastMessage = useCallback((message: string) => {
|
|
setToastMessage(message);
|
|
setShowToast(true);
|
|
}, []);
|
|
|
|
// Handle copy all logs
|
|
const handleCopyAll = useCallback(async () => {
|
|
if (!hasLogs) return;
|
|
|
|
setCopyingAll(true);
|
|
try {
|
|
const content = formatLogsForExport(logs, format);
|
|
const success = await copyToClipboard(content);
|
|
|
|
if (success) {
|
|
showToastMessage(`Copied ${logs.length} log${logs.length !== 1 ? 's' : ''}`);
|
|
onCopySuccess?.();
|
|
} else {
|
|
showToastMessage('Failed to copy');
|
|
}
|
|
} finally {
|
|
setCopyingAll(false);
|
|
}
|
|
}, [logs, format, hasLogs, showToastMessage, onCopySuccess]);
|
|
|
|
// Handle copy selected logs
|
|
const handleCopySelected = useCallback(async () => {
|
|
if (!hasSelection) return;
|
|
|
|
setCopyingSelected(true);
|
|
try {
|
|
const selectedLogs = getSelectedLogs();
|
|
const content = formatLogsForExport(selectedLogs, format);
|
|
const success = await copyToClipboard(content);
|
|
|
|
if (success) {
|
|
showToastMessage(`Copied ${selectedLogs.length} log${selectedLogs.length !== 1 ? 's' : ''}`);
|
|
onCopySuccess?.();
|
|
} else {
|
|
showToastMessage('Failed to copy');
|
|
}
|
|
} finally {
|
|
setCopyingSelected(false);
|
|
}
|
|
}, [getSelectedLogs, format, hasSelection, showToastMessage, onCopySuccess]);
|
|
|
|
// Handle download
|
|
const handleDownload = useCallback(() => {
|
|
const logsToExport = hasSelection ? getSelectedLogs() : logs;
|
|
if (logsToExport.length === 0) return;
|
|
|
|
const content = formatLogsForExport(logsToExport, format);
|
|
const { extension, mimeType } = getExportFileInfo(format);
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
const filename = `logs-${timestamp}.${extension}`;
|
|
|
|
downloadAsFile(content, filename, mimeType);
|
|
showToastMessage(`Downloaded ${logsToExport.length} log${logsToExport.length !== 1 ? 's' : ''}`);
|
|
}, [logs, format, hasSelection, getSelectedLogs, showToastMessage]);
|
|
|
|
return (
|
|
<div className="relative flex items-center gap-2 px-3 py-2 bg-gray-800 border-b border-gray-700">
|
|
{/* Toast notification */}
|
|
<Toast
|
|
message={toastMessage}
|
|
isVisible={showToast}
|
|
onClose={() => setShowToast(false)}
|
|
/>
|
|
|
|
{/* Copy All button */}
|
|
<button
|
|
onClick={handleCopyAll}
|
|
disabled={!hasLogs || copyingAll}
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
|
hasLogs && !copyingAll
|
|
? 'bg-blue-600 text-white hover:bg-blue-500'
|
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
|
}`}
|
|
title="Copy all logs to clipboard"
|
|
>
|
|
{copyingAll ? (
|
|
<span className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
) : (
|
|
<Copy className="w-3.5 h-3.5" />
|
|
)}
|
|
<span>Copy All</span>
|
|
{hasLogs && (
|
|
<span className="px-1.5 py-0.5 bg-blue-700 rounded text-[10px]">{logs.length}</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Copy Selected button */}
|
|
<button
|
|
onClick={handleCopySelected}
|
|
disabled={!hasSelection || copyingSelected}
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
|
hasSelection && !copyingSelected
|
|
? 'bg-green-600 text-white hover:bg-green-500'
|
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
|
}`}
|
|
title={hasSelection ? 'Copy selected logs to clipboard' : 'Select logs to copy'}
|
|
>
|
|
{copyingSelected ? (
|
|
<span className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
) : (
|
|
<Copy className="w-3.5 h-3.5" />
|
|
)}
|
|
<span>Copy Selected</span>
|
|
{hasSelection && (
|
|
<span className="px-1.5 py-0.5 bg-green-700 rounded text-[10px]">
|
|
{selectedIndices.size}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Separator */}
|
|
<div className="w-px h-5 bg-gray-600" />
|
|
|
|
{/* Format dropdown */}
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-gray-400">Format:</span>
|
|
<FormatDropdown value={format} onChange={setFormat} disabled={!hasLogs} />
|
|
</div>
|
|
|
|
{/* Separator */}
|
|
<div className="w-px h-5 bg-gray-600" />
|
|
|
|
{/* Download button */}
|
|
<button
|
|
onClick={handleDownload}
|
|
disabled={!hasLogs && !hasSelection}
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
|
hasLogs || hasSelection
|
|
? 'bg-gray-700 text-gray-300 border border-gray-600 hover:bg-gray-600 hover:text-gray-200'
|
|
: 'bg-gray-800 text-gray-500 border border-gray-700 cursor-not-allowed'
|
|
}`}
|
|
title={hasSelection ? 'Download selected logs as file' : 'Download all logs as file'}
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>Download</span>
|
|
</button>
|
|
|
|
{/* Selection info */}
|
|
{hasSelection && (
|
|
<span className="ml-auto text-xs text-gray-400">
|
|
{selectedIndices.size} of {logs.length} selected
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|