Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CSV Viewer to MessageFiles #42

Merged
merged 7 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 0 additions & 56 deletions src/SessionMessages/SessionMessage/MessageFile.tsx

This file was deleted.

152 changes: 152 additions & 0 deletions src/SessionMessages/SessionMessage/MessageFile/CSVFileRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { FC, useEffect, useState, ReactElement, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { parseCSV } from '@/utils/parseCSV';
import DownloadIcon from '@/assets/download.svg?react';
import PlaceholderIcon from '@/assets/copy.svg?react';

interface CSVFileRendererProps {
name?: string;
url: string;
fileIcon?: ReactElement;
}

/**
* Renderer for CSV files that fetches and displays a snippet of the file data.
*/
const CSVFileRenderer: FC<CSVFileRendererProps> = ({ name, url, fileIcon }) => {
const [csvData, setCsvData] = useState<string[][]>([]);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const fetchCsvData = async () => {
try {
const data = parseCSV();
setCsvData(data);
} catch {
setError('Failed to load CSV file.');
}
};

fetchCsvData();
}, [url]);

const toggleModal = () => {
setIsModalOpen((prev) => !prev);
};

const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
setIsModalOpen(false);
}
};

useEffect(() => {
if (isModalOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isModalOpen]);

const downloadCSV = () => {
if (csvData.length === 0) return;

const csvContent = csvData.map((row) => row.join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${name || 'data'}`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};

const renderTable = (data: string[][], maxRows?: number) => (
<motion.table
layout
className="w-full"
transition={{ type: 'spring', stiffness: 100, damping: 20 }}
>
<thead className="sticky top-0 bg-gray-200 dark:bg-gray-800 z-10">
<tr>
<th className="py-4 px-6">#</th>
{data[0].map((header, index) => (
<th key={`header-${index}`} className="py-4 px-6">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{data.slice(1, maxRows).map((row, rowIndex) => (
<tr
key={`row-${rowIndex}`}
className="border-b border-panel-accent hover:bg-panel-accent/40 transition-colors text-base text-text-secondary"
>
<td className="py-4 px-6">{rowIndex + 1}</td>
{row.map((cell, cellIndex) => (
<td
key={`cell-${rowIndex}-${cellIndex}`}
className="py-4 px-6 dark:bg-vulcan light:bg-mystic"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</motion.table>
);

return (
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<div className="csv-icon flex items-center gap-2">
{fileIcon}
{name && <figcaption className="file-name">{name}</figcaption>}
</div>
<div className="csv-icon flex items-center gap-6">
<DownloadIcon onClick={downloadCSV} className="cursor-pointer" />
<PlaceholderIcon onClick={toggleModal} className="cursor-pointer" />
</div>
</div>

{error && <div className="error-message">{error}</div>}

<div className="flex justify-between">
{!error && csvData.length > 0 && renderTable(csvData, 6)}
</div>

<AnimatePresence>
{isModalOpen && (
<motion.div
className="fixed inset-0 bg-black/70 flex justify-center items-center z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<motion.div
ref={modalRef}
className="bg-white dark:bg-gray-900 rounded-md w-11/12 h-5/6 overflow-auto"
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
exit={{ scale: 0.8 }}
transition={{ duration: 0.3 }}
>
{!error && csvData.length > 0 && renderTable(csvData)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

export default CSVFileRenderer;
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { FC, ReactElement } from 'react';
import FileIcon from '@/assets/file.svg?react';
import { Ellipsis, cn } from 'reablocks';

interface DefaultFileRendererProps {
name?: string;
url: string;
limit?: number;
fileIcon?: ReactElement;
}

/**
* Default renderer for unspecified file types.
*/
const DefaultFileRenderer: FC<DefaultFileRendererProps> = ({
name,
limit = 100,
fileIcon = <FileIcon />,
}) => (
<figure className="flex items-center gap-2">
{fileIcon}
{name && (
<figcaption className={cn('file-name-class')}>
<Ellipsis value={name} limit={limit} />
</figcaption>
)}
</figure>
);

export default DefaultFileRenderer;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FC } from 'react';

interface ImageFileRendererProps {
name?: string;
url: string;
}

/**
* Renderer for image files.
*/
const ImageFileRenderer: FC<ImageFileRendererProps> = ({ url }) => (
<img src={url} alt="Image" className="h-10 w-10" />
SerhiiTsybulskyi marked this conversation as resolved.
Show resolved Hide resolved
);

export default ImageFileRenderer;
58 changes: 58 additions & 0 deletions src/SessionMessages/SessionMessage/MessageFile/MessageFile.tsx
SerhiiTsybulskyi marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { FC, useContext, ReactElement, Suspense, lazy, useMemo } from 'react';
import { ConversationFile } from '@/types';
import { ChatContext } from '@/ChatContext';
import { cn } from 'reablocks';
import FileIcon from '@/assets/file.svg?react';

const DefaultFileRenderer = lazy(() => import('./DefaultFileRenderer'));
const CSVFileRenderer = lazy(() => import('./CSVFileRenderer'));
const ImageFileRenderer = lazy(() => import('./ImageFileRenderer'));
const PDFFileRenderer = lazy(() => import('./PDFFileRenderer'));

export interface MessageFileProps extends ConversationFile {
/**
* Icon to show for delete.
*/
fileIcon?: ReactElement;

/**
* Limit for the name.
*/
limit?: number;
}

/**
* Base MessageFile component that routes to specific file renderers based on file type.
*/
export const MessageFile: FC<MessageFileProps> = ({
name,
type,
url,
limit = 100,
fileIcon = <FileIcon />,
}) => {
const { theme } = useContext(ChatContext);

const fileTypeRendererMap: { [key: string]: FC<any> } = {
SerhiiTsybulskyi marked this conversation as resolved.
Show resolved Hide resolved
'image/': ImageFileRenderer,
'text/csv': CSVFileRenderer,
'application/pdf': PDFFileRenderer,
};

const FileRenderer = useMemo(() => {
const Renderer =
Object.keys(fileTypeRendererMap).find((key) => type?.startsWith(key)) ??
'default';
return fileTypeRendererMap[Renderer] || DefaultFileRenderer;
}, [type]);

return (
<div
className={cn(theme.messages.message.files.file.base)}
>
<Suspense fallback={<div>Loading...</div>}>
<FileRenderer name={name} url={url} fileIcon={fileIcon} limit={limit} />
</Suspense>
</div>
);
};
19 changes: 19 additions & 0 deletions src/SessionMessages/SessionMessage/MessageFile/PDFFileRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { FC, ReactElement } from 'react';

interface PDFFileRendererProps {
name?: string;
url: string;
fileIcon?: ReactElement;
}

/**
* Renderer for PDF files.
*/
const PDFFileRenderer: FC<PDFFileRendererProps> = ({ name, url, fileIcon }) => (
<figure className="csv-icon flex items-center gap-2" onClick={() => window.open(url, '_blank')}>
{fileIcon}
{name && <figcaption className="file-name">{name}</figcaption>}
</figure>
);

export default PDFFileRenderer;
1 change: 1 addition & 0 deletions src/SessionMessages/SessionMessage/MessageFile/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './MessageFile';
Loading
Loading