working on file preview
This commit is contained in:
parent
912851d8b6
commit
8341c2e860
22 changed files with 2075 additions and 22 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
752
src/components/FilePreview/FilePreview.module.css
Normal file
752
src/components/FilePreview/FilePreview.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
246
src/components/FilePreview/FilePreview.tsx
Normal file
246
src/components/FilePreview/FilePreview.tsx
Normal 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;
|
||||
|
||||
|
||||
2
src/components/FilePreview/index.ts
Normal file
2
src/components/FilePreview/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { FilePreview } from './FilePreview';
|
||||
export type { FilePreviewProps } from './FilePreview';
|
||||
21
src/components/FilePreview/renderers/ApplicationRenderer.tsx
Normal file
21
src/components/FilePreview/renderers/ApplicationRenderer.tsx
Normal 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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/components/FilePreview/renderers/ErrorRenderer.tsx
Normal file
24
src/components/FilePreview/renderers/ErrorRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/FilePreview/renderers/ImageRenderer.tsx
Normal file
18
src/components/FilePreview/renderers/ImageRenderer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
506
src/components/FilePreview/renderers/JsonRenderer.tsx
Normal file
506
src/components/FilePreview/renderers/JsonRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/components/FilePreview/renderers/LoadingRenderer.tsx
Normal file
13
src/components/FilePreview/renderers/LoadingRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/FilePreview/renderers/PdfRenderer.tsx
Normal file
19
src/components/FilePreview/renderers/PdfRenderer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
src/components/FilePreview/renderers/TextRenderer.tsx
Normal file
21
src/components/FilePreview/renderers/TextRenderer.tsx
Normal 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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
src/components/FilePreview/renderers/UnsupportedRenderer.tsx
Normal file
26
src/components/FilePreview/renderers/UnsupportedRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/components/FilePreview/renderers/index.ts
Normal file
8
src/components/FilePreview/renderers/index.ts
Normal 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';
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue