fix: csv improvements

This commit is contained in:
Vukasin 2025-08-24 00:13:21 +02:00
parent c18e674f6a
commit 9ae810683d
4 changed files with 276 additions and 154 deletions

View File

@ -4,14 +4,12 @@ import React, { useState, useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CsvTable } from '@/components/ui/csv-table';
import Papa from 'papaparse';
import { cn } from '@/lib/utils';
import {
Search,
ChevronUp,
ChevronDown,
import {
Search,
FileSpreadsheet,
ArrowUpDown,
Filter,
} from 'lucide-react';
import {
@ -49,8 +47,8 @@ function parseCSV(content: string) {
headers = results.meta.fields || [];
}
return {
headers,
return {
headers,
data: results.data,
meta: results.meta
};
@ -68,7 +66,7 @@ export function CsvRenderer({
const [sortConfig, setSortConfig] = useState<SortConfig>({ column: '', direction: null });
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [rowsPerPage] = useState(50);
const [rowsPerPage] = useState(50);
const parsedData = parseCSV(content);
const isEmpty = parsedData.data.length === 0;
@ -88,18 +86,18 @@ export function CsvRenderer({
filtered = [...filtered].sort((a: any, b: any) => {
const aVal = a[sortConfig.column];
const bVal = b[sortConfig.column];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return sortConfig.direction === 'asc' ? -1 : 1;
if (bVal == null) return sortConfig.direction === 'asc' ? 1 : -1;
if (typeof aVal === 'number' && typeof bVal === 'number') {
return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
}
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (aStr < bStr) return sortConfig.direction === 'asc' ? -1 : 1;
if (aStr > bStr) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
@ -139,36 +137,6 @@ export function CsvRenderer({
});
};
const getSortIcon = (column: string) => {
if (sortConfig.column !== column) {
return <ArrowUpDown className="h-3 w-3 text-muted-foreground" />;
}
return sortConfig.direction === 'asc' ?
<ChevronUp className="h-3 w-3 text-primary" /> :
<ChevronDown className="h-3 w-3 text-primary" />;
};
const formatCellValue = (value: any) => {
if (value == null) return '';
if (typeof value === 'number') {
return value.toLocaleString();
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
return String(value);
};
const getCellClassName = (value: any) => {
if (typeof value === 'number') {
return 'text-right font-mono';
}
if (typeof value === 'boolean') {
return value ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
}
return '';
};
if (isEmpty) {
return (
<div className={cn('w-full h-full flex items-center justify-center', className)}>
@ -199,12 +167,12 @@ export function CsvRenderer({
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
Page {currentPage} of {totalPages}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
@ -245,73 +213,14 @@ export function CsvRenderer({
<div className="flex-1 overflow-hidden">
<div className="w-full h-full overflow-auto scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 scrollbar-track-transparent">
<table className="w-full border-collapse table-fixed" style={{ minWidth: `${visibleHeaders.length * 150}px` }}>
<thead className="bg-muted/50 sticky top-0 z-10">
<tr>
{visibleHeaders.map((header, index) => (
<th
key={header}
className="px-4 py-3 text-left font-medium border-b border-border bg-muted/50 backdrop-blur-sm"
style={{ width: '150px', minWidth: '150px' }}
>
<button
onClick={() => handleSort(header)}
className="flex items-center gap-2 hover:text-primary transition-colors group w-full text-left"
>
<span className="truncate">{header}</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{getSortIcon(header)}
</div>
</button>
</th>
))}
</tr>
</thead>
<tbody>
{paginatedData.map((row: any, rowIndex) => (
<tr
key={startIndex + rowIndex}
className="border-b border-border hover:bg-muted/30 transition-colors"
>
{visibleHeaders.map((header, cellIndex) => {
const value = row[header];
return (
<td
key={`${startIndex + rowIndex}-${cellIndex}`}
className={cn(
"px-4 py-3 text-sm border-r border-border last:border-r-0",
getCellClassName(value)
)}
style={{ width: '150px', minWidth: '150px' }}
>
<div className="truncate" title={String(value || '')}>
{formatCellValue(value)}
</div>
</td>
);
})}
</tr>
))}
{/* Empty state for current page */}
{paginatedData.length === 0 && searchTerm && (
<tr>
<td colSpan={visibleHeaders.length} className="py-8 text-center text-muted-foreground">
<div className="space-y-2">
<p>No results found for "{searchTerm}"</p>
<Button
variant="outline"
size="sm"
onClick={() => setSearchTerm('')}
>
Clear search
</Button>
</div>
</td>
</tr>
)}
</tbody>
</table>
<CsvTable
headers={visibleHeaders}
data={paginatedData}
sortConfig={sortConfig}
onSort={handleSort}
searchTerm={searchTerm}
onClearSearch={() => setSearchTerm('')}
/>
</div>
</div>
@ -322,7 +231,7 @@ export function CsvRenderer({
<div className="text-sm text-muted-foreground">
Showing {startIndex + 1} to {Math.min(startIndex + rowsPerPage, processedData.length)} of {processedData.length.toLocaleString()} rows
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
@ -332,7 +241,7 @@ export function CsvRenderer({
>
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
@ -345,7 +254,7 @@ export function CsvRenderer({
} else {
pageNum = currentPage - 2 + i;
}
return (
<Button
key={pageNum}
@ -359,7 +268,7 @@ export function CsvRenderer({
);
})}
</div>
<Button
variant="outline"
size="sm"

View File

@ -553,16 +553,16 @@ export function FileAttachment({
</div>
{/* Header with filename */}
<div className="absolute top-0 left-0 right-0 bg-accent p-2 z-10 flex items-center justify-between">
<div className="absolute top-0 left-0 right-0 bg-accent p-2 h-[40px] z-10 flex items-center justify-between">
<div className="text-sm font-medium truncate">{filename}</div>
<div className="flex items-center gap-1">
<button
{/* <button
onClick={handleDownload}
className="cursor-pointer p-1 rounded-full hover:bg-black/10 dark:hover:bg-white/10"
title="Download file"
>
<Download size={14} />
</button>
</button> */}
{onClick && (
<button
onClick={handleClick}

View File

@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import React, { useState } from 'react';
import { CsvTable } from '@/components/ui/csv-table';
import Papa from 'papaparse';
import { cn } from '@/lib/utils';
@ -35,49 +35,75 @@ function parseCSV(content: string) {
}
/**
* CSV/TSV renderer that presents data in a table format
* Minimal CSV renderer for thread previews using the CsvTable component
*/
export function CsvRenderer({
content,
className
}: CsvRendererProps) {
const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' | null }>({
column: '',
direction: null
});
const parsedData = parseCSV(content);
const isEmpty = parsedData.data.length === 0;
const handleSort = (column: string) => {
setSortConfig(prev => {
if (prev.column === column) {
const newDirection = prev.direction === 'asc' ? 'desc' : prev.direction === 'desc' ? null : 'asc';
return { column: newDirection ? column : '', direction: newDirection };
} else {
return { column, direction: 'asc' };
}
});
};
// Sort the data based on sortConfig
const sortedData = React.useMemo(() => {
if (!sortConfig.column || !sortConfig.direction) {
return parsedData.data;
}
return [...parsedData.data].sort((a: any, b: any) => {
const aVal = a[sortConfig.column];
const bVal = b[sortConfig.column];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return sortConfig.direction === 'asc' ? -1 : 1;
if (bVal == null) return sortConfig.direction === 'asc' ? 1 : -1;
if (typeof aVal === 'number' && typeof bVal === 'number') {
return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
}
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (aStr < bStr) return sortConfig.direction === 'asc' ? -1 : 1;
if (aStr > bStr) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}, [parsedData.data, sortConfig]);
if (isEmpty) {
return (
<div className={cn('w-full h-full flex items-center justify-center', className)}>
<div className="text-muted-foreground text-sm">No data</div>
</div>
);
}
return (
<div className={cn('w-full h-full overflow-hidden', className)}>
<ScrollArea className="w-full h-full">
<div className="p-0">
<table className="w-full border-collapse text-sm">
<thead className="bg-muted sticky top-0">
<tr>
{parsedData.headers.map((header, index) => (
<th key={index} className="px-3 py-2 text-left font-medium border border-border">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{!isEmpty ? parsedData.data.map((row: any, rowIndex) => (
<tr key={rowIndex} className="border-b border-border hover:bg-muted/50">
{parsedData.headers.map((header, cellIndex) => (
<td key={cellIndex} className="px-3 py-2 border border-border">
{row[header] || ''}
</td>
))}
</tr>
)) : (
<tr>
<td colSpan={parsedData.headers.length || 1} className="py-4 text-center text-muted-foreground">
No data available
</td>
</tr>
)}
</tbody>
</table>
</div>
</ScrollArea>
<div className={cn('w-full h-full', className)}>
<CsvTable
headers={parsedData.headers}
data={sortedData}
sortConfig={sortConfig}
onSort={handleSort}
containerHeight={300} // Fixed height for thread preview
/>
</div>
);
}

View File

@ -0,0 +1,187 @@
'use client';
import React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
ChevronUp,
ChevronDown,
ArrowUpDown,
} from 'lucide-react';
interface CsvTableProps {
headers: string[];
data: any[];
sortConfig: { column: string; direction: 'asc' | 'desc' | null };
onSort: (column: string) => void;
searchTerm?: string;
onClearSearch?: () => void;
className?: string;
containerHeight?: number; // Add container height prop
}
export function CsvTable({
headers,
data,
sortConfig,
onSort,
searchTerm,
onClearSearch,
className,
containerHeight = 500
}: CsvTableProps) {
const ROW_HEIGHT = 48; // Height of each row in pixels
const HEADER_HEIGHT = 48; // Height of header row
const COL_WIDTH = 150; // Fixed column width in pixels
// Calculate offset to handle parent containers with fractional heights like calc(100vh-17rem)
// 17rem = 272px (16px base * 17), so we need to offset the grid to align properly
const parentOffset = 272; // 17rem in pixels
const gridOffsetY = parentOffset % ROW_HEIGHT; // Get remainder to align to 48px grid
const getSortIcon = (column: string) => {
if (sortConfig.column !== column) {
return <ArrowUpDown className="h-3 w-3 text-muted-foreground" />;
}
return sortConfig.direction === 'asc' ?
<ChevronUp className="h-3 w-3 text-primary" /> :
<ChevronDown className="h-3 w-3 text-primary" />;
};
const formatCellValue = (value: any) => {
if (value == null) return '';
if (typeof value === 'number') {
return value.toLocaleString();
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
return String(value);
};
const getCellClassName = (value: any) => {
if (typeof value === 'number') {
return 'text-right font-mono';
}
if (typeof value === 'boolean') {
return value ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
}
return '';
};
return (
<div className={cn("w-full relative h-full !bg-card", className)}>
{/* Use CSS Grid for perfect alignment */}
<div
className="w-full h-full overflow-auto relative"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${headers.length}, 150px)`,
gridAutoRows: '48px',
backgroundColor: '#f9fafb',
backgroundImage: `
repeating-linear-gradient(
to right,
transparent 0px,
transparent 149px,
#e5e7eb 149px,
#e5e7eb 150px
),
repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 47px,
#d1d5db 47px,
#d1d5db 48px
)
`,
backgroundSize: '150px 48px',
backgroundPosition: '0 0',
backgroundAttachment: 'local'
}}
>
{/* Header background extension for infinite grid */}
<div
className="sticky top-0 z-10 bg-background pointer-events-none"
style={{
position: 'absolute',
left: `${headers.length * 150}px`,
right: 0,
height: '48px',
top: 0
}}
/>
{/* Header row */}
{headers.map((header, index) => (
<div
key={`header-${index}`}
className="sticky top-0 z-20 bg-background flex items-center px-4 font-medium"
style={{
gridColumn: index + 1,
gridRow: 1,
height: '48px'
}}
>
<button
onClick={() => onSort(header)}
className="flex items-center gap-2 hover:text-primary transition-colors group w-full text-left"
>
<span className="truncate">{header}</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{getSortIcon(header)}
</div>
</button>
</div>
))}
{/* Data rows */}
{data.map((row: any, rowIndex) => (
headers.map((header, cellIndex) => {
const value = row[header];
return (
<div
key={`${rowIndex}-${cellIndex}`}
className={cn(
"flex items-center px-4 text-sm hover:bg-muted/30 transition-colors",
getCellClassName(value)
)}
style={{
gridColumn: cellIndex + 1,
gridRow: rowIndex + 2,
height: '48px'
}}
>
<div className="truncate w-full" title={String(value || '')}>
{formatCellValue(value)}
</div>
</div>
);
})
))}
{/* Empty state for search */}
{data.length === 0 && searchTerm && (
<div
className="col-span-full py-8 text-center text-muted-foreground"
style={{
gridColumn: `1 / ${headers.length + 1}`,
gridRow: 2
}}
>
<div className="space-y-2">
<p>No results found for "{searchTerm}"</p>
{onClearSearch && (
<Button
variant="outline"
size="sm"
onClick={onClearSearch}
>
Clear search
</Button>
)}
</div>
</div>
)}
</div>
</div>
);
}