working on file preview

This commit is contained in:
Ida Dittrich 2025-09-08 15:39:18 +02:00
parent 912851d8b6
commit 8341c2e860
22 changed files with 2075 additions and 22 deletions

View file

@ -1,6 +1,7 @@
import { FormGenerator } from '../FormGenerator';
import { useLanguage } from '../../contexts/LanguageContext';
import { Popup, EditForm } from '../Popup';
import { FilePreview } from '../FilePreview';
import styles from './DateienTable.module.css';
import { useDateienLogic } from './dateienLogic.tsx';
import type { DateienTableProps } from './dateienInterfaces';
@ -18,9 +19,11 @@ export function DateienTable({ className = '' }: DateienTableProps) {
editModalOpen,
editingFile,
editFileFields,
previewModalOpen,
previewingFile,
handleSaveFile,
handleCancelEdit,
refetch,
handleClosePreview,
handleDelete,
handleDeleteMultiple
} = useDateienLogic();
@ -76,6 +79,17 @@ export function DateienTable({ className = '' }: DateienTableProps) {
/>
)}
</Popup>
{/* File Preview Modal */}
{previewingFile && (
<FilePreview
isOpen={previewModalOpen}
onClose={handleClosePreview}
fileId={previewingFile.id}
fileName={previewingFile.file_name}
mimeType={previewingFile.mime_type}
/>
)}
</div>
);
}

View file

@ -77,14 +77,20 @@ export interface DateienLogicReturn {
actions: TableAction[];
downloadingFiles: Set<string>;
deletingFiles: Set<string>;
previewingFiles: Set<string>;
downloadError: string | null;
deleteError: string | null;
previewError: string | null;
editModalOpen: boolean;
editingFile: UserFile | null;
editFileFields: EditFieldConfig[];
previewModalOpen: boolean;
previewingFile: UserFile | null;
handleEditFile: (file: UserFile) => void;
handleSaveFile: (updatedFile: UserFile) => Promise<void>;
handleCancelEdit: () => void;
handlePreviewFile: (file: UserFile) => void;
handleClosePreview: () => void;
handleDelete: (file: UserFile) => Promise<void>;
handleDeleteMultiple: (files: UserFile[]) => Promise<void>;
}

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { IoIosTrash, IoIosDownload } from 'react-icons/io';
import { IoIosTrash, IoIosDownload, IoIosEye } from 'react-icons/io';
import { MdModeEdit } from 'react-icons/md';
import { ColumnConfig } from '../FormGenerator';
@ -22,8 +22,10 @@ export function useDateienLogic(): DateienLogicReturn {
handleFileUpdate,
downloadingFiles,
deletingFiles,
previewingFiles,
downloadError,
deleteError
deleteError,
previewError
} = useFileOperations();
const { t } = useLanguage();
@ -31,6 +33,10 @@ export function useDateienLogic(): DateienLogicReturn {
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
// Preview modal state
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [previewingFile, setPreviewingFile] = useState<UserFile | null>(null);
// Configure edit fields for filename editing
const editFileFields: EditFieldConfig[] = useMemo(() => [
{
@ -90,6 +96,18 @@ export function useDateienLogic(): DateienLogicReturn {
setEditingFile(null);
};
// Handle preview file
const handlePreviewFile = (file: UserFile) => {
setPreviewingFile(file);
setPreviewModalOpen(true);
};
// Handle close preview
const handleClosePreview = () => {
setPreviewModalOpen(false);
setPreviewingFile(null);
};
// Helper function to format file size
const formatFileSize: FileSizeFormatter = (sizeInBytes) => {
if (!sizeInBytes || sizeInBytes === 0) return '-';
@ -332,6 +350,19 @@ export function useDateienLogic(): DateienLogicReturn {
// Configure action buttons
const actions: TableAction[] = useMemo(() => [
{
label: t('files.action.preview', 'Preview'),
icon: (row: UserFile) => {
const isPreviewingThis = previewingFiles.has(row.id);
if (isPreviewingThis) return '⏳';
return <IoIosEye />;
},
onClick: (row: UserFile) => {
if (!previewingFiles.has(row.id)) {
handlePreviewFile(row);
}
}
},
{
label: t('files.action.edit', 'Edit'),
icon: <MdModeEdit />,
@ -365,7 +396,7 @@ export function useDateienLogic(): DateienLogicReturn {
}
}
}
], [t, downloadingFiles, deletingFiles, handleDownload, handleDelete]);
], [t, previewingFiles, downloadingFiles, deletingFiles, handleDownload, handleDelete]);
return {
files,
@ -376,14 +407,20 @@ export function useDateienLogic(): DateienLogicReturn {
actions,
downloadingFiles,
deletingFiles,
previewingFiles,
downloadError,
deleteError,
previewError,
editModalOpen,
editingFile,
editFileFields,
previewModalOpen,
previewingFile,
handleEditFile,
handleSaveFile,
handleCancelEdit,
handlePreviewFile,
handleClosePreview,
handleDelete,
handleDeleteMultiple
};

View file

@ -0,0 +1,752 @@
.previewContainer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--color-background)!important;
border-radius: 8px;
overflow: hidden;
position: relative;
}
/* Ensure all child elements have white background */
.previewContainer * {
background-color: var(--color-background) !important;
}
/* Loading State */
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
color: var(--color-text);
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-primary);
border-top: 4px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Error State */
.errorContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
color: var(--color-error);
text-align: center;
}
.errorIcon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.retryButton {
background: var(--color-primary);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s ease;
}
.retryButton:hover {
background: var(--color-primary-hover);
}
/* Image Preview */
.previewImage {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Iframe Preview (for PDFs, text files, etc.) */
.previewIframe {
width: 100%;
height: 100%;
border: none;
border-radius: 4px;
background: var(--color-background) !important;
color: var(--color-text) !important;
}
/* Force iframe content to have white background */
.previewIframe::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--color-background) !important;
z-index: -1;
}
/* Unsupported File Type */
.unsupportedContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
color: var(--color-text);
text-align: center;
}
.unsupportedIcon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.6;
}
.fileName {
font-weight: 500;
font-size: 1.1rem;
color: var(--color-text);
margin: 0.5rem 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.previewContainer {
padding: 1rem;
}
.loadingContainer,
.errorContainer,
.unsupportedContainer {
padding: 1rem;
}
.previewImage {
max-height: 70vh;
}
.previewIframe {
height: 70vh;
}
}
/* JSON Container */
.jsonContainer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--color-background) !important;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* JSON Header */
.jsonHeader {
background: var(--color-background) !important;
border-bottom: 1px solid #e9ecef;
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
}
.jsonHeaderRight {
display: flex;
align-items: center;
gap: 12px;
}
.jsonTitle {
font-weight: 600;
color: #495057;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.jsonSize {
color: var(--color-text);
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Table Layout - Row-wise rendering */
.jsonTable {
flex: 1;
overflow: hidden;
background: var(--color-background) !important;
display: flex;
flex-direction: column;
}
/* Collapsible functionality */
.collapseButton {
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
margin-left: 8px;
font-size: 12px;
color: var(--color-gray);
border-radius: 3px;
transition: all 0.2s;
font-weight: bold;
min-width: 20px;
text-align: center;
}
.collapseButton:hover {
background-color: var(--color-gray-hover);
color: #333;
}
.collapseButton:active {
transform: scale(0.95);
}
.valueContainer {
display: flex;
flex-direction: column;
width: 100%;
overflow: visible !important;
min-width: 0;
}
.jsonValuePreview {
color: #666;
font-style: italic;
background: var(--color-background);
padding: 4px 8px;
border-radius: 4px;
border-left: 3px solid var(--color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
display: block;
font-size: 12px;
}
.jsonValue {
word-wrap: break-word;
white-space: pre-wrap;
max-width: 100%;
display: block;
position: relative;
z-index: 10;
min-height: 18px;
}
.collapsedRow {
background-color: #f8f9fa;
opacity: 0.8;
}
.collapsedRow .jsonTableKey {
border-left: 2px solid var(--color-secondary);
}
.notCollapsedRow {
background-color: #f8f9fa;
border-left: 2px solid var(--color-background);
}
.collapsedRow:hover {
opacity: 1;
}
.jsonTableBody {
flex: 1;
overflow-y: auto;
background: var(--color-background) !important;
}
.jsonTableRow {
display: flex;
border-bottom: 1px solid var(--color-primary);
transition: background-color 0.2s ease;
}
.jsonTableRow:hover {
background: var(--color-background);
}
.jsonTableKey {
flex: 0 0 200px;
padding: 12px 16px;
border-right: 1px solid var(--color-primary);
border-left: 2px solid var(--color-background);
background: var(--color-background);
display: flex;
align-items: flex-start;
box-sizing: border-box;
}
.jsonTableValue {
flex: 1;
padding: 12px 16px;
background: var(--color-background) !important;
display: flex;
align-items: flex-start;
box-sizing: border-box;
}
.jsonKey {
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'Courier New', monospace;
font-size: 13px;
font-weight: 600;
color: var(--color-text);
word-break: break-all;
background: transparent;
line-height: 1.4;
width: 100%;
}
.jsonValue {
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'Courier New', monospace;
font-size: 13px;
color: var(--color-text);
word-break: break-word;
white-space: pre-wrap;
background: transparent;
line-height: 1.4;
width: 100%;
position: relative;
z-index: 10;
min-height: 18px;
}
/* Type-specific styling */
.jsonValueString {
color: var(--color-text);
font-weight: 600;
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'Courier New', monospace;
font-size: 13px;
white-space: nowrap !important;
}
.jsonValueNumber {
color: var(--color-text);
font-weight: 600;
white-space: nowrap !important;
word-break: keep-all !important;
overflow: visible !important;
}
.jsonValueBoolean {
color: var(--color-text);
font-weight: 600;
white-space: nowrap !important;
word-break: keep-all !important;
overflow: visible !important;
}
.jsonValueNull {
color: var(--color-text);
font-style: italic;
}
.jsonValueUndefined {
color: var(--color-text);
font-style: italic;
}
.jsonValueArray {
color: #fd7e14;
font-weight: 600;
}
.jsonValueObject {
color: #6f42c1;
font-weight: 600;
}
.jsonValueTimestamp {
color: var(--color-text);
font-weight: 600;
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'Courier New', monospace;
}
/* Dark mode support for JSON table layout */
[data-theme="dark"] .jsonTableHeader {
background: #2d3748;
border-bottom-color: #4a5568;
color: #e2e8f0;
}
[data-theme="dark"] .jsonTableKeyHeader {
background: #2d3748;
border-right-color: #4a5568;
}
[data-theme="dark"] .jsonTableValueHeader {
background: #2d3748;
}
[data-theme="dark"] .jsonTableBody {
background: #1a202c !important;
}
[data-theme="dark"] .jsonTableRow {
border-bottom-color: #2d3748;
}
[data-theme="dark"] .jsonTableRow:hover {
background: #2d3748;
}
[data-theme="dark"] .jsonTableKey {
background: #2d3748;
border-right-color: #4a5568;
}
[data-theme="dark"] .jsonTableValue {
background: #1a202c !important;
}
[data-theme="dark"] .jsonKey {
color: #e2e8f0;
}
[data-theme="dark"] .jsonValue {
color: #e2e8f0;
}
[data-theme="dark"] .jsonValueString {
color: #63b3ed;
}
[data-theme="dark"] .jsonValueNumber {
color: #68d391;
}
[data-theme="dark"] .jsonValueBoolean {
color: #fc8181;
}
[data-theme="dark"] .jsonValueNull,
[data-theme="dark"] .jsonValueUndefined {
color: #a0aec0;
}
[data-theme="dark"] .jsonValueArray {
color: #f6ad55;
}
[data-theme="dark"] .jsonValueObject {
color: #b794f6;
}
[data-theme="dark"] .jsonValueTimestamp {
color: #4fd1c7;
}
/* Dark mode for collapsible functionality */
[data-theme="dark"] .collapseButton {
color: #a0aec0;
}
[data-theme="dark"] .collapseButton:hover {
background-color: #4a5568;
}
[data-theme="dark"] .jsonValuePreview {
color: #a0aec0;
background: #2d3748;
border-left-color: #4a5568;
}
[data-theme="dark"] .collapsedRow {
background-color: #2d3748;
}
[data-theme="dark"] .collapsedRow .jsonTableKey {
border-left-color: #63b3ed;
}
[data-theme="dark"] .collapsedRow:hover {
background-color: #4a5568;
}
/* Nested Table Styles */
.nestedTable {
margin-top: 8px;
border-radius: 4px;
background: #f8f9fa;
overflow: visible !important;
width: 100%;
min-width: 100%;
}
.nestedTableKeyHeader {
flex: 0 0 150px;
padding: 8px 12px;
border-right: 1px solid #dee2e6;
background: #e9ecef;
}
.nestedTableValueHeader {
flex: 1;
padding: 8px 12px;
background: #e9ecef;
}
.nestedTableBody {
background: var(--color-background) !important;
}
.nestedTableRow {
display: flex;
transition: background-color 0.2s ease;
overflow: visible !important;
min-height: 30px;
}
.nestedTableRow:hover {
background: var(--color-background);
}
.nestedTableRow:last-child {
border-bottom: none;
}
.nestedTableKey {
flex: 0 0 150px;
padding: 8px 12px;
background: var(--color-background);
display: flex;
align-items: flex-start;
box-sizing: border-box;
}
.nestedTableValue {
flex: 1;
padding: 8px 12px;
background: var(--color-background) !important;
display: flex;
align-items: flex-start;
box-sizing: border-box;
position: relative;
z-index: 1;
overflow: visible !important;
min-width: 0;
width: 100%;
}
.nestedValueSummary {
margin-bottom: 8px;
font-weight: 500;
color: var(--color-text);
}
/* Array items display */
.arrayItems {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
/* Array items when no key is shown (should span full width) */
.arrayItemsFullWidth {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
margin-left: -183px;
padding-left: 16px;
width: calc(100% + 183px);
}
.arrayItem {
display: flex;
align-items: center;
padding: 2px 6px;
background: #f8f9fa;
border-radius: 3px;
font-size: 12px;
}
.arrayValue {
color: var(--color-text);
font-weight: 400;
}
.arrayPreview {
color: var(--color-light-gray);
font-size: 12px;
font-style: italic;
padding: 4px 8px;
background: var(--color-background);
border-radius: 3px;
border: 1px solid green;
}
/* JSON Syntax Highlighting */
.jsonCode {
color: #212529;
background: white !important;
}
/* Key highlighting */
.jsonCode {
background: linear-gradient(90deg,
transparent 0%,
transparent 100%
);
}
/* Add some basic syntax highlighting using CSS */
.jsonCode::before {
content: '';
display: block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
pointer-events: none;
background:
/* Strings - green */
linear-gradient(90deg, transparent 0%, transparent 100%),
/* Numbers - blue */
linear-gradient(90deg, transparent 0%, transparent 100%),
/* Booleans - purple */
linear-gradient(90deg, transparent 0%, transparent 100%),
/* Null - gray */
linear-gradient(90deg, transparent 0%, transparent 100%);
}
/* Scrollbar styling */
.jsonPreview::-webkit-scrollbar {
width: 8px;
height: 8px;
background: var(--color-background) !important;
}
.jsonPreview::-webkit-scrollbar-track {
background: var(--color-background) !important;
border-radius: 4px;
}
.jsonPreview::-webkit-scrollbar-thumb {
background: #c1c1c1 !important;
border-radius: 4px;
}
.jsonPreview::-webkit-scrollbar-thumb:hover {
background: #a8a8a8 !important;
}
/* JSON Structure Indicators */
.jsonPreview {
position: relative;
background: var(--color-background) !important;
}
/* Add line numbers */
.jsonPreview::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 40px;
height: 100%;
background: var(--color-background) !important;
border-right: 1px solid #e9ecef;
z-index: 1;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.previewIframe {
background: white !important;
color: black !important;
}
/* Only apply dark background for non-HTML content */
.previewIframe[data-mime-type*="application/pdf"] {
background: #1a1a1a !important;
}
/* Keep JSON files with light background for readability */
.previewIframe[data-mime-type*="application/json"] {
background: var(--color-background) !important;
color: black !important;
}
.jsonPreview {
background: var(--color-background) !important;
color: black !important;
}
/* Dark mode for JSON container */
.jsonContainer {
background: #1e1e1e;
color: #d4d4d4;
}
.jsonHeader {
background: #2d2d30;
border-bottom-color: #3e3e42;
}
.jsonTitle {
color: #cccccc;
}
.jsonSize {
color: #969696;
}
.jsonPreview {
background: #1e1e1e !important;
color: #d4d4d4 !important;
}
.jsonPreview::before {
background: #2d2d30;
border-right-color: #3e3e42;
}
.jsonPreview::-webkit-scrollbar-track {
background: #2d2d30;
}
.jsonPreview::-webkit-scrollbar-thumb {
background: #555;
}
.jsonPreview::-webkit-scrollbar-thumb:hover {
background: #777;
}
}

View file

@ -0,0 +1,246 @@
import { useState, useEffect } from 'react';
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
import { Popup, PopupAction } from '../Popup/Popup';
import { useLanguage } from '../../contexts/LanguageContext';
import { useFileOperations } from '../../hooks/useFiles';
import {
JsonRenderer,
ImageRenderer,
TextRenderer,
PdfRenderer,
ApplicationRenderer,
UnsupportedRenderer,
LoadingRenderer,
ErrorRenderer
} from './renderers';
import styles from './FilePreview.module.css';
export interface FilePreviewProps {
isOpen: boolean;
onClose: () => void;
fileId: string;
fileName: string;
mimeType?: string;
}
export function FilePreview({
isOpen,
onClose,
fileId,
fileName,
mimeType
}: FilePreviewProps) {
const { t } = useLanguage();
const { handleFilePreview, handleFileDownload, previewingFiles, previewError, downloadingFiles } = useFileOperations();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewContent, setPreviewContent] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState<boolean>(false);
// Clean up blob URL when component unmounts or preview changes
useEffect(() => {
return () => {
if (previewUrl) {
window.URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
// Load preview when modal opens
useEffect(() => {
if (isOpen && fileId) {
loadPreview();
} else {
// Clean up when modal closes
if (previewUrl) {
window.URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
setError(null);
}
}, [isOpen, fileId]);
const loadPreview = async () => {
try {
setError(null);
setPreviewContent(null);
const result = await handleFilePreview(fileId, fileName);
if (result.success && result.previewUrl) {
setPreviewUrl(result.previewUrl);
if (result.decodedContent) {
setPreviewContent(result.decodedContent);
}
} else {
setError(result.error || 'Failed to load preview');
}
} catch (err) {
setError('An unexpected error occurred while loading the preview');
}
};
const handleCopyContent = async () => {
try {
if (previewContent) {
await navigator.clipboard.writeText(previewContent);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} else {
// Fallback: try to copy from preview URL if it's a text file
if (previewUrl && mimeType?.startsWith('text/')) {
const response = await fetch(previewUrl);
const text = await response.text();
await navigator.clipboard.writeText(text);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
}
}
} catch (err) {
console.error('Failed to copy content:', err);
}
};
const handleDownloadFile = async () => {
try {
await handleFileDownload(fileId, fileName);
} catch (err) {
console.error('Failed to download file:', err);
}
};
const isPreviewing = previewingFiles.has(fileId);
const hasError = error || previewError;
// Create action buttons for the popup header
const actions: PopupAction[] = [
// Copy Content Button - only show for text-based files (exclude PDFs)
...(mimeType !== 'application/pdf' && (mimeType?.startsWith('text/') || mimeType === 'application/json' || previewContent) ? [{
label: copySuccess ? t('files.preview.copied', 'Copied!') : t(''),
icon: copySuccess ? '✓' : <IoIosCopy />,
onClick: handleCopyContent,
disabled: !previewContent && !previewUrl,
variant: 'primary' as const
}] : []),
// Download Button
{
label: String(''),
icon: downloadingFiles.has(fileId) ? undefined : <IoIosDownload />,
onClick: handleDownloadFile,
disabled: downloadingFiles.has(fileId),
loading: downloadingFiles.has(fileId),
variant: 'success' as const
}
];
const renderPreview = () => {
if (!previewUrl) {
if (isPreviewing) {
return <LoadingRenderer />;
}
if (hasError) {
return <ErrorRenderer error={hasError} onRetry={loadPreview} />;
}
return null;
}
// For JSON files with decoded content, use JsonRenderer
if (previewContent && mimeType === 'application/json') {
return <JsonRenderer previewContent={previewContent} fileName={fileName} />;
}
if (mimeType === 'application/json') {
return (
<div className={styles.jsonContainer}>
<div className={styles.jsonHeader}>
<span className={styles.jsonTitle}>JSON Preview (Fallback)</span>
<div className={styles.jsonHeaderRight}>
<span className={styles.jsonSize}>Raw content</span>
</div>
</div>
<pre className={styles.jsonPreview}>
<code className={styles.jsonCode}>
{previewContent || 'No content available'}
</code>
</pre>
</div>
);
}
// Determine preview type based on MIME type
const mimePrefix = mimeType?.split('/')[0];
switch (mimePrefix) {
case 'image':
return (
<ImageRenderer
previewUrl={previewUrl}
fileName={fileName}
onError={() => setError('Failed to load image preview')}
/>
);
case 'text':
return (
<TextRenderer
previewUrl={previewUrl}
fileName={fileName}
mimeType={mimeType}
onError={() => setError('Failed to load text preview')}
/>
);
case 'application':
if (mimeType === 'application/pdf') {
return (
<PdfRenderer
previewUrl={previewUrl}
fileName={fileName}
onError={() => setError('Failed to load PDF preview')}
/>
);
}
return (
<ApplicationRenderer
previewUrl={previewUrl}
fileName={fileName}
mimeType={mimeType}
onError={() => setError('Preview not supported for this file type')}
/>
);
default:
return <UnsupportedRenderer previewUrl={previewUrl} fileName={fileName} />;
}
};
return (
<Popup
isOpen={isOpen}
onClose={onClose}
title={`${t('files.preview.title', 'File Preview')}: ${fileName}`}
size="fullscreen"
className={styles.filePreviewPopup}
actions={actions}
>
<div className={styles.previewContainer}>
{renderPreview()}
</div>
</Popup>
);
}
export default FilePreview;

View file

@ -0,0 +1,2 @@
export { FilePreview } from './FilePreview';
export type { FilePreviewProps } from './FilePreview';

View file

@ -0,0 +1,21 @@
import styles from '../FilePreview.module.css';
interface ApplicationRendererProps {
previewUrl: string;
fileName: string;
mimeType?: string;
onError: () => void;
}
export function ApplicationRenderer({ previewUrl, fileName, mimeType, onError }: ApplicationRendererProps) {
return (
<iframe
src={previewUrl}
className={styles.previewIframe}
title={`Preview of ${fileName}`}
data-mime-type={mimeType}
onError={onError}
style={{ background: 'white !important' }}
/>
);
}

View file

@ -0,0 +1,24 @@
import { useLanguage } from '../../../contexts/LanguageContext';
import styles from '../FilePreview.module.css';
interface ErrorRendererProps {
error: string;
onRetry: () => void;
}
export function ErrorRenderer({ error, onRetry }: ErrorRendererProps) {
const { t } = useLanguage();
return (
<div className={styles.errorContainer}>
<div className={styles.errorIcon}></div>
<p>{error}</p>
<button
onClick={onRetry}
className={styles.retryButton}
>
{t('common.retry', 'Retry')}
</button>
</div>
);
}

View file

@ -0,0 +1,18 @@
import styles from '../FilePreview.module.css';
interface ImageRendererProps {
previewUrl: string;
fileName: string;
onError: () => void;
}
export function ImageRenderer({ previewUrl, fileName, onError }: ImageRendererProps) {
return (
<img
src={previewUrl}
alt={fileName}
className={styles.previewImage}
onError={onError}
/>
);
}

View file

@ -0,0 +1,506 @@
import { useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import styles from '../FilePreview.module.css';
interface JsonRendererProps {
previewContent: string;
fileName: string;
}
export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
const { t } = useLanguage();
const [collapsedRows, setCollapsedRows] = useState<Set<string>>(new Set());
const handleCopyJson = () => {
try {
const parsedJson = JSON.parse(previewContent);
const formattedJson = JSON.stringify(parsedJson, null, 2);
navigator.clipboard.writeText(formattedJson);
} catch (error) {
navigator.clipboard.writeText(previewContent);
}
};
const formatTimestamp = (value: string | number): string => {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (!isNaN(numValue) && numValue > 1000000000 && numValue < 4102444800) {
const date = new Date(numValue * 1000);
if (!isNaN(date.getTime())) {
return date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
}
return String(value);
};
const preprocessJson = (obj: any, parentKey = ''): {keys: string[], values: any[], types: (string | 'timestamp')[], isNested: boolean[]} => {
const keys: string[] = [];
const values: any[] = [];
const types: (string | 'timestamp')[] = [];
const isNested: boolean[] = [];
if (obj === null || obj === undefined) {
keys.push(parentKey);
values.push(String(obj));
types.push(typeof obj);
isNested.push(false);
} else if (typeof obj === 'boolean' || typeof obj === 'number') {
keys.push(parentKey);
if (typeof obj === 'number' && (parentKey.toLowerCase().includes('timestamp') || parentKey.toLowerCase().includes('time'))) {
values.push(formatTimestamp(obj));
types.push('timestamp');
} else {
values.push(obj);
types.push(typeof obj);
}
isNested.push(false);
} else if (typeof obj === 'string') {
keys.push(parentKey);
const numValue = parseFloat(obj);
if (!isNaN(numValue) && (parentKey.toLowerCase().includes('timestamp') || parentKey.toLowerCase().includes('time'))) {
values.push(formatTimestamp(obj));
types.push('timestamp');
} else {
try {
const parsedString = JSON.parse(obj);
if (typeof parsedString === 'object' && parsedString !== null) {
// For stringified JSON objects, process them recursively
const nestedData = preprocessJson(parsedString, '');
values.push(nestedData);
types.push(Array.isArray(parsedString) ? 'array' : 'object');
isNested.push(true);
} else {
values.push(obj);
types.push('string');
isNested.push(false);
}
} catch (e) {
values.push(obj);
types.push('string');
isNested.push(false);
}
}
} else if (Array.isArray(obj)) {
if (obj.length === 0) {
keys.push(parentKey);
values.push('');
types.push('array');
isNested.push(false);
} else {
const allPrimitive = obj.every(item =>
item === null ||
typeof item !== 'object' ||
(typeof item === 'object' && !Array.isArray(item) && Object.keys(item).length === 0)
);
if (allPrimitive) {
if (parentKey) {
keys.push(parentKey);
values.push({
isArray: true,
items: obj,
length: obj.length
});
types.push('array');
isNested.push(true);
} else {
// For root-level arrays, always use compact array display
keys.push('');
values.push({
isArray: true,
items: obj,
length: obj.length
});
types.push('array');
isNested.push(true);
}
} else {
const nestedKeys: string[] = [];
const nestedValues: any[] = [];
const nestedTypes: (string | 'timestamp')[] = [];
const nestedIsNested: boolean[] = [];
obj.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
const nestedData = preprocessJson(item, `Item ${index + 1}`);
nestedKeys.push(...nestedData.keys);
nestedValues.push(...nestedData.values);
nestedTypes.push(...nestedData.types);
nestedIsNested.push(...nestedData.isNested);
} else {
nestedKeys.push(`Item ${index + 1}`);
nestedValues.push(String(item));
nestedTypes.push(typeof item);
nestedIsNested.push(false);
}
});
if (parentKey) {
keys.push(parentKey);
values.push({
keys: nestedKeys,
values: nestedValues,
types: nestedTypes,
isNested: nestedIsNested
});
types.push('array');
isNested.push(true);
} else {
keys.push(...nestedKeys);
values.push(...nestedValues);
types.push(...nestedTypes);
isNested.push(...nestedIsNested);
}
}
}
} else if (typeof obj === 'object') {
const entries = Object.entries(obj);
if (entries.length === 0) {
keys.push(parentKey);
values.push('{}');
types.push('object');
isNested.push(false);
} else {
const nestedKeys: string[] = [];
const nestedValues: any[] = [];
const nestedTypes: (string | 'timestamp')[] = [];
const nestedIsNested: boolean[] = [];
entries.forEach(([key, value]) => {
if (typeof value === 'object' && value !== null) {
const nestedData = preprocessJson(value, key);
nestedKeys.push(...nestedData.keys);
nestedValues.push(...nestedData.values);
nestedTypes.push(...nestedData.types);
nestedIsNested.push(...nestedData.isNested);
} else {
let processedValue = value;
let processedType: string | 'timestamp' = typeof value;
if (typeof value === 'number' && (key.toLowerCase().includes('timestamp') || key.toLowerCase().includes('time'))) {
processedValue = formatTimestamp(value);
processedType = 'timestamp';
} else if (typeof value === 'string') {
const numValue = parseFloat(value);
if (!isNaN(numValue) && (key.toLowerCase().includes('timestamp') || key.toLowerCase().includes('time'))) {
processedValue = formatTimestamp(value);
processedType = 'timestamp';
}
}
nestedKeys.push(key);
nestedValues.push(processedValue);
nestedTypes.push(processedType);
nestedIsNested.push(false);
}
});
if (parentKey) {
keys.push(parentKey);
values.push({
keys: nestedKeys,
values: nestedValues,
types: nestedTypes,
isNested: nestedIsNested
});
types.push('object');
isNested.push(true);
} else {
keys.push(...nestedKeys);
values.push(...nestedValues);
types.push(...nestedTypes);
isNested.push(...nestedIsNested);
}
}
}
return { keys, values, types, isNested };
};
const renderTable = (data: {keys: string[], values: any[], types: (string | 'timestamp')[], isNested: boolean[]}, level = 0, parentPath = '') => {
if (!data || data.keys.length === 0) return null;
const toggleCollapse = (rowPath: string) => {
const newCollapsed = new Set(collapsedRows);
if (newCollapsed.has(rowPath)) {
newCollapsed.delete(rowPath);
} else {
newCollapsed.add(rowPath);
}
setCollapsedRows(newCollapsed);
};
const isLongContent = (value: any, type: string): boolean => {
if (typeof value === 'string') {
return value.includes('\n') || value.length > 80;
}
if (type === 'array' && Array.isArray(value)) {
return value.length > 8;
}
if (typeof value === 'object' && value !== null && 'keys' in value) {
return value.keys.length > 2;
}
if (typeof value === 'object' && value !== null && 'isArray' in value) {
return value.length > 8;
}
return false;
};
const getPreview = (value: any, type: string): string => {
if (typeof value === 'string') {
if (value.includes('\n')) {
const firstLine = value.split('\n')[0];
return firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine + '...';
}
if (value.length > 80) {
return value.substring(0, 80) + '...';
}
return value;
}
if (type === 'array' && Array.isArray(value)) {
return `[${value.slice(0, 3).map(item => typeof item === 'string' ? `"${item}"` : String(item)).join(', ')}${value.length > 3 ? '...' : ''}]`;
}
if (typeof value === 'object' && value !== null && 'isArray' in value) {
return `[${value.items.slice(0, 3).map((item: any) => typeof item === 'string' ? `"${item}"` : String(item)).join(', ')}${value.length > 3 ? '...' : ''}]`;
}
if (typeof value === 'object' && value !== null && 'keys' in value) {
if (type === 'array') {
return `[${value.keys.slice(0, 3).join(', ')}${value.keys.length > 3 ? '...' : ''}]`;
} else {
return `{${value.keys.slice(0, 3).join(', ')}${value.keys.length > 3 ? '...' : ''}}`;
}
}
return String(value);
};
return (
<div className={`${level > 0 ? styles.nestedTable : styles.jsonTable} ${level > 0 ? styles.nestedTableIndented : ''}`}>
<div className={level > 0 ? styles.nestedTableBody : styles.jsonTableBody}>
{data.keys.map((key, index) => {
const typeClass = data.types[index] ? `jsonValue${data.types[index].charAt(0).toUpperCase() + data.types[index].slice(1)}` : '';
const typeClassName = styles[typeClass] || '';
const rowPath = `${parentPath}.${key}`;
const isCollapsed = collapsedRows.has(rowPath);
const shouldShowCollapse = isLongContent(data.values[index], data.types[index]);
return (
<div
key={index}
className={`${level > 0 ? styles.nestedTableRow : styles.jsonTableRow} ${isCollapsed ? styles.collapsedRow : styles.notCollapsedRow}`}
>
<div className={level > 0 ? styles.nestedTableKey : styles.jsonTableKey}>
<span className={styles.jsonKey}>{key}</span>
{shouldShowCollapse && (
<button
className={styles.collapseButton}
onClick={() => toggleCollapse(rowPath)}
title={isCollapsed ? 'Expand' : 'Collapse'}
>
{isCollapsed ? '▶' : '▼'}
</button>
)}
</div>
<div className={`${level > 0 ? styles.nestedTableValue : styles.jsonTableValue} ${typeClassName}`}>
{data.isNested[index] ? (
<div>
{shouldShowCollapse && isCollapsed ? (
<span className={styles.jsonValuePreview}>
{getPreview(data.values[index], data.types[index])}
</span>
) : (
data.values[index].isArray ? (
<div className={data.keys[index] === '' ? styles.arrayItemsFullWidth : styles.arrayItems}>
{isCollapsed ? (
<div className={styles.arrayPreview}>
{data.values[index].items.slice(0, 10).map((item: any) => String(item)).join(', ')}
{data.values[index].length > 10 && `, ... (${data.values[index].length} items)`}
</div>
) : (
<>
{data.values[index].items.map((item: any, itemIndex: number) => (
<div key={itemIndex} className={styles.arrayItem}>
<span className={`${styles.arrayValue} ${typeof item === 'number' ? styles.jsonValueNumber : typeof item === 'string' ? styles.jsonValueString : styles.jsonValue}`}>
{String(item)}
</span>
</div>
))}
</>
)}
</div>
) : (
typeof data.values[index] === 'object' && data.values[index] !== null && 'keys' in data.values[index] ?
renderTable(data.values[index], level + 1, rowPath) :
<span className={styles.jsonValue}>Error: Invalid nested data</span>
)
)}
</div>
) : (
<div className={styles.valueContainer}>
{shouldShowCollapse && isCollapsed ? (
<span className={styles.jsonValuePreview}>
{getPreview(data.values[index], data.types[index])}
</span>
) : (
<span
className={`${styles.jsonValue} ${
data.types[index] === 'number' ? styles.jsonValueNumber :
data.types[index] === 'boolean' ? styles.jsonValueBoolean :
data.types[index] === 'string' ? styles.jsonValueString : ''
}`}
>
{String(data.values[index])}
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
try {
const parsedJson = JSON.parse(previewContent);
let preprocessedData;
if (Array.isArray(parsedJson)) {
preprocessedData = preprocessJson(parsedJson, 'root');
} else if (typeof parsedJson === 'object' && parsedJson !== null) {
if (parsedJson.result && typeof parsedJson.result === 'string') {
try {
const parsedResult = JSON.parse(parsedJson.result);
parsedJson.result = parsedResult;
} catch (e) {
// If parsing fails, keep as string - it will be handled by the string processing logic
}
}
const entries = Object.entries(parsedJson);
preprocessedData = {
keys: entries.map(([key]) => key),
values: entries.map(([key, value]) => {
if (typeof value === 'object' && value !== null) {
const processed = preprocessJson(value, '');
return processed;
} else if (typeof value === 'string') {
try {
const parsedString = JSON.parse(value);
if (typeof parsedString === 'object' && parsedString !== null) {
return preprocessJson(parsedString, '');
} else {
let processedValue = value.replace(/\\n/g, ' ').replace(/\n/g, ' ');
const numValue = parseFloat(value);
if (!isNaN(numValue) && (key.toLowerCase().includes('timestamp') || key.toLowerCase().includes('time'))) {
processedValue = formatTimestamp(value);
}
return String(processedValue);
}
} catch (e) {
let processedValue = value.replace(/\\n/g, ' ').replace(/\n/g, ' ');
const numValue = parseFloat(value);
if (!isNaN(numValue) && (key.toLowerCase().includes('timestamp') || key.toLowerCase().includes('time'))) {
processedValue = formatTimestamp(value);
}
return String(processedValue);
}
} else {
let processedValue = value;
if (typeof value === 'number' && (key.toLowerCase().includes('timestamp') || key.toLowerCase().includes('time'))) {
processedValue = formatTimestamp(value);
return String(processedValue);
}
return processedValue;
}
}),
types: entries.map(([, value]) => {
if (typeof value === 'object' && value !== null) {
return Array.isArray(value) ? 'array' : 'object';
} else if (typeof value === 'string') {
try {
const parsedString = JSON.parse(value);
if (typeof parsedString === 'object' && parsedString !== null) {
return Array.isArray(parsedString) ? 'array' : 'object';
}
} catch (e) {
// Ignore parsing errors
}
return 'string';
}
return typeof value;
}),
isNested: entries.map(([, value]) => {
if (typeof value === 'object' && value !== null) {
return true;
} else if (typeof value === 'string') {
try {
const parsedString = JSON.parse(value);
return typeof parsedString === 'object' && parsedString !== null;
} catch (e) {
return false;
}
}
return false;
})
};
console.log(preprocessedData);
} else {
preprocessedData = {
keys: ['value'],
values: [String(parsedJson)],
types: [typeof parsedJson],
isNested: [false]
};
}
return (
<div className={styles.jsonContainer}>
<div className={styles.jsonHeader}>
<div className={styles.jsonHeaderRight}>
<span className={styles.jsonSize}>{preprocessedData.keys.length} {t('files.preview.json.properties', 'properties')}</span>
</div>
</div>
{renderTable(preprocessedData, 0, 'root')}
</div>
);
} catch (parseError) {
const rawData = {
keys: ['Raw Content'],
values: [previewContent],
types: ['string'],
isNested: [false]
};
return (
<div className={styles.jsonContainer}>
<div className={styles.jsonHeader}>
<span className={styles.jsonTitle}>{t('files.preview.json.invalid', 'Raw Content (Invalid JSON)')}: {fileName}</span>
<div className={styles.jsonHeaderRight}>
<button
className={styles.copyButton}
onClick={handleCopyJson}
title={t('files.preview.json.copyRaw', 'Copy content to clipboard')}
>
📋 {t('common.copy', 'Copy')}
</button>
</div>
</div>
{renderTable(rawData)}
</div>
);
}
}

View file

@ -0,0 +1,13 @@
import { useLanguage } from '../../../contexts/LanguageContext';
import styles from '../FilePreview.module.css';
export function LoadingRenderer() {
const { t } = useLanguage();
return (
<div className={styles.loadingContainer}>
<div className={styles.spinner}></div>
<p>{t('files.preview.loading', 'Loading preview...')}</p>
</div>
);
}

View file

@ -0,0 +1,19 @@
import styles from '../FilePreview.module.css';
interface PdfRendererProps {
previewUrl: string;
fileName: string;
onError: () => void;
}
export function PdfRenderer({ previewUrl, fileName, onError }: PdfRendererProps) {
return (
<iframe
src={previewUrl}
className={styles.previewIframe}
title={`Preview of ${fileName}`}
data-mime-type="application/pdf"
onError={onError}
/>
);
}

View file

@ -0,0 +1,21 @@
import styles from '../FilePreview.module.css';
interface TextRendererProps {
previewUrl: string;
fileName: string;
mimeType?: string;
onError: () => void;
}
export function TextRenderer({ previewUrl, fileName, mimeType, onError }: TextRendererProps) {
return (
<iframe
src={previewUrl}
className={styles.previewIframe}
title={`Preview of ${fileName}`}
data-mime-type={mimeType}
onError={onError}
style={{ background: 'white !important' }}
/>
);
}

View file

@ -0,0 +1,26 @@
import { useLanguage } from '../../../contexts/LanguageContext';
import styles from '../FilePreview.module.css';
interface UnsupportedRendererProps {
previewUrl: string;
fileName: string;
}
export function UnsupportedRenderer({ previewUrl, fileName }: UnsupportedRendererProps) {
const { t } = useLanguage();
return (
<div className={styles.unsupportedContainer}>
<div className={styles.unsupportedIcon}>📄</div>
<p>{t('files.preview.unsupported', 'Preview not available for this file type')}</p>
<p className={styles.fileName}>{fileName}</p>
<a
href={previewUrl}
download={fileName}
className={styles.downloadButton}
>
{t('files.action.download', 'Download')}
</a>
</div>
);
}

View file

@ -0,0 +1,8 @@
export { JsonRenderer } from './JsonRenderer';
export { ImageRenderer } from './ImageRenderer';
export { TextRenderer } from './TextRenderer';
export { PdfRenderer } from './PdfRenderer';
export { ApplicationRenderer } from './ApplicationRenderer';
export { UnsupportedRenderer } from './UnsupportedRenderer';
export { LoadingRenderer } from './LoadingRenderer';
export { ErrorRenderer } from './ErrorRenderer';

View file

@ -77,23 +77,90 @@
font-size: 18px;
font-weight: 400;
color: var(--color-text);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-left: 16px;
}
.actionButton {
display: flex;
height: 35px;
width: 35px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 15px;
border: none;
border-radius: 25px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-secondary);
color: white;
}
.actionButton:hover {
background: var(--color-secondary-hover);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.actionButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.closeButton {
background: none;
background: var(--color-primary);
border: none;
font-size: 24px;
color: var(--color-primary);
font-size: 18px;
color: #3A3A3A;
cursor: pointer;
padding: 4px;
border-radius: 4px;
padding: 8px;
border-radius: 25px;
transition: all 0.2s ease;
line-height: 1;
margin-left: 8px;
display: flex;
align-items: center;
justify-content: center;
height: 35px;
width: 35px;
}
.closeButton:hover {
background-color: var(--color-bg);
color: var(--color-primary-hover);
background-color: var(--color-primary-hover);
color: #3A3A3A;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Content section */
@ -114,6 +181,43 @@
flex-shrink: 0;
}
/* Dark mode support for action buttons */
[data-theme="dark"] .actionButton.primary {
background: #3182ce;
}
[data-theme="dark"] .actionButton.primary:hover:not(:disabled) {
background: #2c5aa0;
box-shadow: 0 2px 4px rgba(49, 130, 206, 0.3);
}
[data-theme="dark"] .actionButton.secondary {
background: #4a5568;
}
[data-theme="dark"] .actionButton.secondary:hover:not(:disabled) {
background: #2d3748;
box-shadow: 0 2px 4px rgba(74, 85, 104, 0.3);
}
[data-theme="dark"] .actionButton.success {
background: #38a169;
}
[data-theme="dark"] .actionButton.success:hover:not(:disabled) {
background: #2f855a;
box-shadow: 0 2px 4px rgba(56, 161, 105, 0.3);
}
[data-theme="dark"] .actionButton.danger {
background: #e53e3e;
}
[data-theme="dark"] .actionButton.danger:hover:not(:disabled) {
background: #c53030;
box-shadow: 0 2px 4px rgba(229, 62, 62, 0.3);
}
/* Responsive design */
@media (max-width: 640px) {
.popup {
@ -139,6 +243,25 @@
.header {
padding: 16px;
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.title {
text-align: center;
margin-bottom: 8px;
}
.headerActions {
justify-content: center;
flex-wrap: wrap;
margin-left: 0;
}
.actionButton {
padding: 6px 12px;
font-size: 12px;
}
.footer {

View file

@ -1,6 +1,16 @@
import React from 'react';
import styles from './Popup.module.css';
// Action button interface
export interface PopupAction {
label: string;
icon?: string | React.ReactElement;
onClick: () => void;
disabled?: boolean;
loading?: boolean;
variant?: 'primary' | 'secondary' | 'success' | 'danger';
}
// Generic popup props
export interface PopupProps {
isOpen: boolean;
@ -11,6 +21,7 @@ export interface PopupProps {
className?: string;
size?: 'small' | 'medium' | 'large' | 'fullscreen';
closable?: boolean;
actions?: PopupAction[];
}
// Generic Popup component - can be used for any content
@ -22,7 +33,8 @@ export function Popup({
footerContent,
className = '',
size = 'medium',
closable = true
closable = true,
actions = []
}: PopupProps) {
// Handle escape key
@ -60,15 +72,45 @@ export function Popup({
{/* Header */}
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
{closable && (
<button
className={styles.closeButton}
onClick={onClose}
aria-label="Close"
>
×
</button>
)}
<div className={styles.headerActions}>
{/* Action buttons */}
{actions.map((action, index) => (
<button
key={index}
className={`${styles.actionButton} ${styles[action.variant || 'primary']}`}
onClick={action.onClick}
disabled={action.disabled || action.loading}
title={action.label}
>
{action.loading ? (
<>
<span className={styles.spinner}></span>
{action.label}
</>
) : (
<>
{action.icon && (
<span style={{ fontSize: '18px' }}>
{typeof action.icon === 'string' ? action.icon : action.icon}
</span>
)}
{action.label}
</>
)}
</button>
))}
{/* Close button */}
{closable && (
<button
className={styles.closeButton}
onClick={onClose}
aria-label="Close"
>
×
</button>
)}
</div>
</div>
{/* Content */}

View file

@ -1,6 +1,6 @@
// Generic Popup components
export { Popup, default as DefaultPopup } from './Popup';
export type { PopupProps } from './Popup';
export type { PopupProps, PopupAction } from './Popup';
// EditForm component
export { EditForm } from './EditForm';

View file

@ -185,6 +185,8 @@ export function useFileOperations() {
const [downloadError, setDownloadError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [previewingFiles, setPreviewingFiles] = useState<Set<string>>(new Set());
const [previewError, setPreviewError] = useState<string | null>(null);
const handleFileDownload = async (fileId: string, fileName: string) => {
setDownloadError(null);
@ -416,6 +418,155 @@ export function useFileOperations() {
}
};
const handleFilePreview = async (fileId: string, fileName: string) => {
setPreviewError(null);
setPreviewingFiles(prev => new Set(prev).add(fileId));
try {
console.log(`👁️ Starting preview for file: ${fileName} (ID: ${fileId})`);
// First try to get JSON response (for text-based files)
try {
const jsonResponse = await request({
url: `/api/files/${fileId}/preview`,
method: 'get',
additionalConfig: {
responseType: 'json',
validateStatus: function (status: number) {
return status >= 200 && status < 300;
}
}
});
console.log(`✅ JSON preview successful for: ${fileName}`, jsonResponse);
// Check if response has content field (structured response)
if (jsonResponse && typeof jsonResponse === 'object' && 'content' in jsonResponse) {
const content = jsonResponse.content;
const mimeType = jsonResponse.mimeType || 'text/plain';
console.log('📄 Structured JSON response detected:', {
hasContent: !!content,
mimeType: mimeType,
contentLength: content?.length,
contentPreview: content?.substring(0, 100) + '...'
});
// Check if content is base64 encoded (common pattern)
let decodedContent = content;
try {
// Try to decode as base64 if it looks like base64
if (content && typeof content === 'string' && /^[A-Za-z0-9+/=]+$/.test(content)) {
console.log('📄 Content appears to be base64 encoded, attempting decode...');
decodedContent = atob(content);
console.log('📄 Base64 decode successful:', {
originalLength: content.length,
decodedLength: decodedContent.length,
decodedPreview: decodedContent.substring(0, 200) + '...'
});
}
} catch (decodeError) {
console.log('📄 Base64 decode failed, using original content:', decodeError);
decodedContent = content;
}
// Create a blob from the (possibly decoded) content
const blob = new Blob([decodedContent], { type: mimeType });
const url = window.URL.createObjectURL(blob);
console.log('🔗 Created blob URL:', url);
return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent };
} else if (jsonResponse && typeof jsonResponse === 'object' && 'result' in jsonResponse) {
// Handle base64 encoded content in 'result' field
console.log('📄 Base64 encoded content detected in result field');
try {
// Decode base64 content
const decodedContent = atob(jsonResponse.result);
const mimeType = jsonResponse.mimeType || 'application/json';
console.log('📄 Decoded content:', {
length: decodedContent.length,
preview: decodedContent.substring(0, 200) + '...',
mimeType: mimeType,
originalResult: jsonResponse.result.substring(0, 100) + '...',
decodedFirstChars: decodedContent.substring(0, 50)
});
// Create a blob from the decoded content
const blob = new Blob([decodedContent], { type: mimeType });
const url = window.URL.createObjectURL(blob);
console.log('🔗 Created blob URL for decoded content:', url);
console.log('🔍 Blob details:', {
size: blob.size,
type: blob.type,
url: url
});
return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent };
} catch (decodeError) {
console.error('❌ Failed to decode base64 content:', decodeError);
// Fallback to treating as raw JSON
const blob = new Blob([JSON.stringify(jsonResponse, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
return { success: true, previewUrl: url, blob: blob, isJsonContent: true };
}
} else {
console.log('📄 Raw JSON response, treating as content');
// If it's not structured JSON, treat as raw content
const blob = new Blob([JSON.stringify(jsonResponse, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
return { success: true, previewUrl: url, blob: blob, isJsonContent: true };
}
} catch (jsonError) {
console.log('JSON preview failed, trying blob response...', jsonError);
// Fallback to blob response for binary files
const previewData = await request({
url: `/api/files/${fileId}/preview`,
method: 'get',
additionalConfig: {
responseType: 'blob',
validateStatus: function (status: number) {
return status >= 200 && status < 300;
}
}
});
console.log(`✅ Blob preview successful for: ${fileName}`, { size: previewData.size, type: previewData.type });
// Create a blob URL for preview
const url = window.URL.createObjectURL(previewData);
return { success: true, previewUrl: url, blob: previewData, isJsonContent: false };
}
} catch (error: any) {
console.error(`❌ Preview failed for ${fileName}:`, error);
let errorMessage = error.message;
if (error.response?.status === 404) {
errorMessage = `File "${fileName}" not found or has been deleted.`;
} else if (error.response?.status === 403) {
errorMessage = `No permission to preview "${fileName}".`;
} else if (error.response?.status === 415) {
errorMessage = `File type "${fileName}" is not supported for preview.`;
}
setPreviewError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setPreviewingFiles(prev => {
const newSet = new Set(prev);
newSet.delete(fileId);
return newSet;
});
}
};
return {
downloadingFiles,
deletingFiles,
@ -423,10 +574,13 @@ export function useFileOperations() {
downloadError,
deleteError,
uploadError,
previewingFiles,
previewError,
handleFileDownload,
handleFileDelete,
handleFileUpload,
handleFileUpdate,
handleFilePreview,
isLoading
};
}

View file

@ -365,9 +365,16 @@ export default {
'files.type.file': 'Datei',
// File Actions
'files.action.preview': 'Vorschau',
'files.action.download': 'Herunterladen',
'files.action.delete': 'Löschen',
'files.delete.confirm': 'Sind Sie sicher, dass Sie die Datei "{name}" löschen möchten?',
// File Preview
'files.preview.title': 'Dateivorschau',
'files.preview.loading': 'Vorschau wird geladen...',
'files.preview.unsupported': 'Vorschau für diesen Dateityp nicht verfügbar',
'files.preview.error': 'Fehler beim Laden der Vorschau',
// Workflows Page
'workflows.title': 'Workflows',

View file

@ -368,9 +368,16 @@ export default {
// File Actions
'files.action.preview': 'Preview',
'files.action.download': 'Download',
'files.action.delete': 'Delete',
'files.delete.confirm': 'Are you sure you want to delete the file "{name}"?',
// File Preview
'files.preview.title': 'File Preview',
'files.preview.loading': 'Loading preview...',
'files.preview.unsupported': 'Preview not available for this file type',
'files.preview.error': 'Error loading preview',
// Workflows Page
'workflows.title': 'Workflows',

View file

@ -368,9 +368,16 @@ export default {
// File Actions
'files.action.preview': 'Aperçu',
'files.action.download': 'Télécharger',
'files.action.delete': 'Supprimer',
'files.delete.confirm': 'Êtes-vous sûr de vouloir supprimer le fichier "{name}"?',
// File Preview
'files.preview.title': 'Aperçu du fichier',
'files.preview.loading': 'Chargement de l\'aperçu...',
'files.preview.unsupported': 'Aperçu non disponible pour ce type de fichier',
'files.preview.error': 'Erreur lors du chargement de l\'aperçu',
// Workflows Page
'workflows.title': 'Workflows',