added file attachement for messages and fixed preview tool
This commit is contained in:
parent
057abf4a88
commit
56c33869ac
28 changed files with 4162 additions and 345 deletions
1104
package-lock.json
generated
1104
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -25,6 +25,7 @@
|
|||
"react-dom": "^19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'https://gateway.poweron-center.net',
|
||||
//baseURL: 'http://localhost:8000',
|
||||
//baseURL: 'https://gateway.poweron-center.net',
|
||||
baseURL: 'http://localhost:8000',
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@
|
|||
|
||||
.horizontalLine {
|
||||
width: 100%;
|
||||
background-color: black;
|
||||
height: 2px;
|
||||
margin-top: 19px;
|
||||
background-color: var(--Grayscale-Black, #24262B);
|
||||
height: 1px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.horizontalLineLight {
|
||||
|
|
|
|||
|
|
@ -38,14 +38,28 @@
|
|||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.messages_spacer {
|
||||
flex: 1;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.chat_input {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.input_row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message_input {
|
||||
|
|
@ -67,10 +81,10 @@
|
|||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.send_button {
|
||||
padding: 12px 12px;
|
||||
background-color: var(--Brand-Green-Green, #3A8088);
|
||||
color: white;
|
||||
.attachment_button {
|
||||
padding: 11px 11px;
|
||||
background-color: #e6f2f2;
|
||||
color: var(--Brand-Green-Green, #3A8088);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
|
|
@ -79,6 +93,88 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.attachment_button:hover {
|
||||
background-color: #cce3e4;
|
||||
color: var(--Brand-Green-Green, #3A8088);
|
||||
}
|
||||
|
||||
.attachment_button:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.attached_files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.attached_file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background-color: #f0f8f8;
|
||||
border: 1px solid #cce7e8;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #3a8088;
|
||||
}
|
||||
|
||||
.attached_file_icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.attached_file_name {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attached_file_remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.attached_file_remove:hover {
|
||||
background-color: #ddd;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.send_button {
|
||||
padding: 12px 12px;
|
||||
background-color: var(--Brand-Green-Green, #3A8088);
|
||||
color: white;
|
||||
border: 1px solid var(--Brand-Green-Green, #3A8088);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.send_button:hover {
|
||||
background-color: #34737b;
|
||||
}
|
||||
|
||||
.send_button_icon {
|
||||
|
|
@ -92,6 +188,7 @@
|
|||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.stop_button {
|
||||
|
|
@ -135,15 +232,15 @@
|
|||
|
||||
.error_message {
|
||||
padding: 10px;
|
||||
background-color: #ffebee;
|
||||
border-left: 4px solid #f44336;
|
||||
background-color: #fceff0;
|
||||
border-left: 4px solid #d85d67;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.error_message p {
|
||||
margin: 0;
|
||||
color: #c62828;
|
||||
color: #d85d67;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
|
@ -205,31 +302,31 @@
|
|||
|
||||
.workflow_status {
|
||||
padding: 8px 12px;
|
||||
background-color: #e8f5e8;
|
||||
border-left: 4px solid #4caf50;
|
||||
background-color: #e6f2f2;
|
||||
border-left: 4px solid #3a8088;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.workflow_status p {
|
||||
margin: 0;
|
||||
color: #2e7d32;
|
||||
color: #3a8088;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.completion_message {
|
||||
padding: 10px 12px;
|
||||
background-color: #e8f5e8;
|
||||
border-left: 4px solid #4caf50;
|
||||
background-color: #e6f2f2;
|
||||
border-left: 4px solid #3a8088;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.completion_message p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2e7d32;
|
||||
color: #3a8088;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -238,7 +335,7 @@
|
|||
background-color: var(--Brand-Green-Green, #3A8088);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 15px;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
|
@ -249,3 +346,108 @@
|
|||
.new_workflow_button:hover {
|
||||
background-color: #2d6b73;
|
||||
}
|
||||
|
||||
.message_documents {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.document_item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.document_item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.message_assistant .document_item {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.message_assistant .document_item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.document_icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.document_info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document_name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.document_meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.document_size {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document_type {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document_actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.document_action_button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.document_action_button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.message_assistant .document_action_button {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.message_assistant .document_action_button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ const DashboardChatArea: React.FC<DashboardChatAreaProps> = ({
|
|||
setInputValue,
|
||||
currentWorkflowId,
|
||||
workflowCompleted,
|
||||
attachedFiles,
|
||||
|
||||
// Refs
|
||||
inputRef,
|
||||
|
|
@ -36,6 +37,9 @@ const DashboardChatArea: React.FC<DashboardChatAreaProps> = ({
|
|||
handleKeyPress,
|
||||
startNewWorkflow,
|
||||
handleStopWorkflow,
|
||||
handleFileAttach,
|
||||
handleFileRemove,
|
||||
handleFilesSelect,
|
||||
|
||||
// Workflow state
|
||||
isWorkflowRunning,
|
||||
|
|
@ -81,6 +85,10 @@ const DashboardChatArea: React.FC<DashboardChatAreaProps> = ({
|
|||
isWorkflowRunning={isWorkflowRunning}
|
||||
onStopWorkflow={handleStopWorkflow}
|
||||
isStoppingWorkflow={isStoppingWorkflow}
|
||||
attachedFiles={attachedFiles}
|
||||
onFileAttach={handleFileAttach}
|
||||
onFileRemove={handleFileRemove}
|
||||
onFilesSelect={handleFilesSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,33 @@
|
|||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { LuSendHorizontal } from "react-icons/lu";
|
||||
import { FaStop } from "react-icons/fa";
|
||||
import { IoAttach, IoClose } from "react-icons/io5";
|
||||
import { ChatInputProps } from "./dashboardChatAreaTypes";
|
||||
import { FileInfo } from "../../../../hooks/useFiles";
|
||||
import DateienSelector from "../../../Dateien/DateienHinzufügen/DateienSelector";
|
||||
import styles from './DashboardChatArea.module.css';
|
||||
|
||||
// Helper function to get file icon based on type
|
||||
const getFileIcon = (mimeType?: string): string => {
|
||||
if (!mimeType) return '📄';
|
||||
|
||||
const type = mimeType.toLowerCase();
|
||||
|
||||
if (type.includes('image')) return '🖼️';
|
||||
if (type.includes('video')) return '🎥';
|
||||
if (type.includes('audio')) return '🎵';
|
||||
if (type.includes('pdf')) return '📕';
|
||||
if (type.includes('word') || type.includes('document')) return '📘';
|
||||
if (type.includes('excel') || type.includes('spreadsheet')) return '📊';
|
||||
if (type.includes('powerpoint') || type.includes('presentation')) return '📋';
|
||||
if (type.includes('text')) return '📝';
|
||||
if (type.includes('zip') || type.includes('archive')) return '📦';
|
||||
if (type.includes('javascript') || type.includes('json') || type.includes('html') || type.includes('css')) return '💻';
|
||||
|
||||
return '📄';
|
||||
};
|
||||
|
||||
const ChatInput: React.FC<ChatInputProps> = ({
|
||||
inputValue,
|
||||
setInputValue,
|
||||
|
|
@ -15,8 +38,27 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
inputRef,
|
||||
isWorkflowRunning,
|
||||
onStopWorkflow,
|
||||
isStoppingWorkflow
|
||||
isStoppingWorkflow,
|
||||
attachedFiles,
|
||||
onFileAttach,
|
||||
onFileRemove,
|
||||
onFilesSelect
|
||||
}) => {
|
||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||
|
||||
const handleAttachmentClick = () => {
|
||||
setIsUploadModalOpen(true);
|
||||
};
|
||||
|
||||
const handleFilesSelected = (files: FileInfo[]) => {
|
||||
onFilesSelect(files);
|
||||
setIsUploadModalOpen(false);
|
||||
};
|
||||
|
||||
const handleFileRemove = (fileId: number) => {
|
||||
onFileRemove(fileId);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.chat_input}
|
||||
|
|
@ -24,6 +66,31 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
{/* Show attached files if any */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className={styles.attached_files}>
|
||||
{attachedFiles.map((file) => (
|
||||
<div key={file.id} className={styles.attached_file}>
|
||||
<span className={styles.attached_file_icon}>
|
||||
{getFileIcon(file.mimeType)}
|
||||
</span>
|
||||
<span className={styles.attached_file_name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<button
|
||||
className={styles.attached_file_remove}
|
||||
onClick={() => handleFileRemove(file.id)}
|
||||
title="Datei entfernen"
|
||||
>
|
||||
<IoClose size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input row with text input, attachment button, and send button */}
|
||||
<div className={styles.input_row}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
|
|
@ -34,10 +101,24 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
className={styles.message_input}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
{/* Attachment button */}
|
||||
<motion.button
|
||||
className={styles.attachment_button}
|
||||
onClick={handleAttachmentClick}
|
||||
disabled={isDisabled || isWorkflowRunning}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
title="Datei anhängen"
|
||||
>
|
||||
<IoAttach size={18} />
|
||||
</motion.button>
|
||||
|
||||
{/* Send/Stop button */}
|
||||
<motion.button
|
||||
className={isWorkflowRunning ? styles.stop_button : styles.send_button}
|
||||
onClick={isWorkflowRunning ? onStopWorkflow : onSend}
|
||||
disabled={isWorkflowRunning ? isStoppingWorkflow : (isDisabled || !inputValue.trim())}
|
||||
disabled={isWorkflowRunning ? isStoppingWorkflow : (isDisabled || (!inputValue.trim() && attachedFiles.length === 0))}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
|
|
@ -47,6 +128,14 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
<LuSendHorizontal className={styles.send_button_icon}/>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
<DateienSelector
|
||||
isOpen={isUploadModalOpen}
|
||||
onClose={() => setIsUploadModalOpen(false)}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import React from "react";
|
||||
import { Message } from "./dashboardChatAreaTypes";
|
||||
import React, { useState } from "react";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { MdOutlineRemoveRedEye } from "react-icons/md";
|
||||
import { Message, Document } from "./dashboardChatAreaTypes";
|
||||
import FilePreviewPopup from "./FilePreviewPopup";
|
||||
import styles from './DashboardChatArea.module.css';
|
||||
|
||||
interface MessageItemProps {
|
||||
|
|
@ -7,7 +10,100 @@ interface MessageItemProps {
|
|||
index: number;
|
||||
}
|
||||
|
||||
// Helper function to format file size
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
if (!bytes) return '';
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Helper function to get file icon based on type or extension
|
||||
const getFileIcon = (type?: string, ext?: string): string => {
|
||||
// Use extension first if available, then fall back to MIME type
|
||||
const extension = ext?.toLowerCase();
|
||||
const mimeType = type?.toLowerCase();
|
||||
|
||||
// Check extension first
|
||||
if (extension) {
|
||||
// Images
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) return '🖼️';
|
||||
// Videos
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) return '🎥';
|
||||
// Audio
|
||||
if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'wma'].includes(extension)) return '🎵';
|
||||
// Documents
|
||||
if (extension === 'pdf') return '📕';
|
||||
if (['doc', 'docx'].includes(extension)) return '📘';
|
||||
if (['xls', 'xlsx'].includes(extension)) return '📊';
|
||||
if (['ppt', 'pptx'].includes(extension)) return '📋';
|
||||
if (['txt', 'md', 'rtf'].includes(extension)) return '📝';
|
||||
// Archives
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) return '📦';
|
||||
// Code files
|
||||
if (['js', 'ts', 'jsx', 'tsx', 'html', 'css', 'py', 'java', 'cpp', 'c', 'php'].includes(extension)) return '💻';
|
||||
}
|
||||
|
||||
// Fall back to MIME type if extension didn't match
|
||||
if (mimeType) {
|
||||
if (mimeType.includes('image')) return '🖼️';
|
||||
if (mimeType.includes('video')) return '🎥';
|
||||
if (mimeType.includes('audio')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📕';
|
||||
if (mimeType.includes('text')) return '📝';
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return '📘';
|
||||
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return '📊';
|
||||
if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return '📋';
|
||||
if (mimeType.includes('zip') || mimeType.includes('archive')) return '📦';
|
||||
}
|
||||
|
||||
return '📄';
|
||||
};
|
||||
|
||||
const MessageItem: React.FC<MessageItemProps> = ({ message, index }) => {
|
||||
const [previewDocument, setPreviewDocument] = useState<Document | null>(null);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
|
||||
const handleDocumentClick = (document: Document) => {
|
||||
// If there's a downloadUrl, use it; otherwise try the url
|
||||
const downloadLink = document.downloadUrl || document.url;
|
||||
|
||||
if (downloadLink) {
|
||||
// Open the document in a new tab
|
||||
window.open(downloadLink, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = (document: Document, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
console.log('handlePreview called with document:', document);
|
||||
console.log('document.id:', document.id, 'document.fileId:', document.fileId);
|
||||
|
||||
// Use fileId if available, otherwise try to use id as fallback
|
||||
const fileId = document.fileId || document.id;
|
||||
|
||||
if (!fileId) {
|
||||
console.error('Neither fileId nor id is available on document:', document);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Using fileId for preview:', fileId, 'type:', typeof fileId);
|
||||
|
||||
setPreviewDocument(document);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewDocument(null);
|
||||
};
|
||||
|
||||
const handleDownload = (document: Document, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// TODO: Implement download functionality
|
||||
console.log('Download document:', document.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id || index}
|
||||
|
|
@ -20,11 +116,66 @@ const MessageItem: React.FC<MessageItemProps> = ({ message, index }) => {
|
|||
<div className={styles.message_content}>
|
||||
{message.content}
|
||||
</div>
|
||||
|
||||
{message.documents && message.documents.length > 0 && (
|
||||
<div className={styles.message_documents}>
|
||||
{message.documents.map((document, docIndex) => (
|
||||
<div
|
||||
key={document.id || docIndex}
|
||||
className={styles.document_item}
|
||||
onClick={() => handleDocumentClick(document)}
|
||||
title={`Click to open ${document.name}`}
|
||||
>
|
||||
<span className={styles.document_icon}>
|
||||
{getFileIcon(document.type, document.ext)}
|
||||
</span>
|
||||
<div className={styles.document_info}>
|
||||
<div className={styles.document_name}>
|
||||
{document.ext ? `${document.name}.${document.ext}` : document.name}
|
||||
</div>
|
||||
<div className={styles.document_meta}>
|
||||
{document.size && (
|
||||
<span className={styles.document_size}>
|
||||
{formatFileSize(document.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.document_actions}>
|
||||
<button
|
||||
className={styles.document_action_button}
|
||||
onClick={(e) => handlePreview(document, e)}
|
||||
title="Preview document"
|
||||
>
|
||||
<MdOutlineRemoveRedEye />
|
||||
</button>
|
||||
<button
|
||||
className={styles.document_action_button}
|
||||
onClick={(e) => handleDownload(document, e)}
|
||||
title="Download document"
|
||||
>
|
||||
<FaDownload />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.timestamp && (
|
||||
<div className={styles.message_timestamp}>
|
||||
{new Date(message.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Preview Popup */}
|
||||
{previewDocument && (
|
||||
<FilePreviewPopup
|
||||
document={previewDocument}
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={handleClosePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,9 +54,12 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||
/>
|
||||
))
|
||||
) : !currentWorkflowId ? (
|
||||
<p className={styles.placeholder_text}>Start a conversation by typing a message...</p>
|
||||
<p className={styles.placeholder_text}>Start a conversation by typing a message, selecting a prompt or continuing a previous workflow...</p>
|
||||
) : null}
|
||||
|
||||
{/* Spacer to push workflow status to bottom when there are fewer messages */}
|
||||
{messages.length < 3 && <div className={styles.messages_spacer} />}
|
||||
|
||||
<WorkflowStatusDisplay
|
||||
currentWorkflowId={currentWorkflowId}
|
||||
workflowStatus={workflowStatus}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,452 @@
|
|||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.popup {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
max-width: 90vw;
|
||||
height: 90vh;
|
||||
width: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background-color: #f9fafb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.close_button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close_button:hover {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #dc2626;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no_preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
font-style: italic;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image_preview {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.pdf_preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.text_preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.enhanced_text_preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
overflow: auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.7;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.text_line {
|
||||
margin-bottom: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.text_line_break {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.text_header {
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
color: #1f2937;
|
||||
margin: 16px 0 8px 0;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.text_numbered {
|
||||
margin: 8px 0;
|
||||
padding-left: 8px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.text_bullet {
|
||||
margin: 4px 0;
|
||||
padding-left: 8px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.text_indented {
|
||||
background-color: #f8fafc;
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0;
|
||||
border-left: 3px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.code_preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #f9fafb;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.python_code_preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code_header {
|
||||
background-color: #161b22;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code_language {
|
||||
background-color: #1f6feb;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.code_filename {
|
||||
color: #7d8590;
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.python_code_content {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background-color: #0d1117;
|
||||
color: #e6edf3;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.python_code_content code {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown_preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.markdown_preview h1 {
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.markdown_preview h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
margin: 24px 0 12px 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.markdown_preview h3 {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
margin: 20px 0 8px 0;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.markdown_preview h4,
|
||||
.markdown_preview h5,
|
||||
.markdown_preview h6 {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 8px 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.markdown_preview p {
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.7;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.markdown_preview ul,
|
||||
.markdown_preview ol {
|
||||
margin: 0 0 16px 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown_preview li {
|
||||
margin: 4px 0;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.markdown_preview blockquote {
|
||||
margin: 16px 0;
|
||||
padding: 12px 16px;
|
||||
background-color: #f8fafc;
|
||||
border-left: 4px solid #3b82f6;
|
||||
color: #4b5563;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown_preview code {
|
||||
background-color: #f1f5f9;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875em;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.markdown_preview pre {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 16px 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown_preview pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.markdown_preview table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.markdown_preview th,
|
||||
.markdown_preview td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.markdown_preview th {
|
||||
background-color: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.markdown_preview td {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.markdown_preview a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown_preview a:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.markdown_preview hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background-color: #e2e8f0;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.markdown_preview strong {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.markdown_preview em {
|
||||
font-style: italic;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.popup {
|
||||
width: 95vw;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pdf_preview {
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import React, { useEffect } from "react";
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { MdClose } from "react-icons/md";
|
||||
import { Document } from "./dashboardChatAreaTypes";
|
||||
import { useFilePreview } from "../../../../hooks/useWorkflows";
|
||||
import styles from './FilePreviewPopup.module.css';
|
||||
|
||||
interface FilePreviewPopupProps {
|
||||
document: Document;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const FilePreviewPopup: React.FC<FilePreviewPopupProps> = ({ document, isOpen, onClose }) => {
|
||||
const { previewContent, fileMetadata, isLoading, error, fetchPreview, clearPreview } = useFilePreview();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && document) {
|
||||
// Use fileId if available, otherwise try to use id as fallback
|
||||
const fileId = document.fileId || document.id;
|
||||
|
||||
if (fileId) {
|
||||
console.log('FilePreviewPopup: calling fetchPreview with fileId:', fileId);
|
||||
fetchPreview(String(fileId));
|
||||
} else {
|
||||
console.error('FilePreviewPopup: No fileId or id available on document:', document);
|
||||
}
|
||||
} else if (!isOpen) {
|
||||
clearPreview();
|
||||
}
|
||||
}, [isOpen, document.fileId, document.id]);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const getPreviewComponent = () => {
|
||||
if (isLoading) {
|
||||
return <div className={styles.loading}>Loading preview...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
<div>Error: {error}</div>
|
||||
{fileMetadata && (
|
||||
<div style={{ marginTop: '10px', fontSize: '12px', opacity: 0.7 }}>
|
||||
Debug: {JSON.stringify(fileMetadata, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!previewContent) {
|
||||
return (
|
||||
<div className={styles.no_preview}>
|
||||
<div>No preview available</div>
|
||||
{fileMetadata && (
|
||||
<div style={{ marginTop: '10px', fontSize: '12px', opacity: 0.7 }}>
|
||||
Available metadata: {Object.keys(fileMetadata).join(', ')}
|
||||
<br />
|
||||
Preview field: {fileMetadata.preview ? 'has data' : 'empty/null'}
|
||||
<br />
|
||||
Base64Encoded: {String(fileMetadata.base64Encoded)}
|
||||
<br />
|
||||
MimeType: {fileMetadata.mimeType}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Use metadata from backend response
|
||||
const mimeType = fileMetadata?.mimeType;
|
||||
const isBase64Encoded = fileMetadata?.base64Encoded;
|
||||
const fileExtension = document.ext?.toLowerCase();
|
||||
|
||||
// Check if this is a markdown file by extension/MIME type first
|
||||
const isMarkdownByType = fileExtension === 'md' ||
|
||||
fileExtension === 'markdown' ||
|
||||
mimeType === 'text/markdown' ||
|
||||
mimeType === 'text/x-markdown';
|
||||
|
||||
// Content-based markdown detection for .txt files with markdown content
|
||||
// BUT NOT for specific code file types
|
||||
const isCodeFile = fileExtension === 'py' ||
|
||||
fileExtension === 'js' ||
|
||||
fileExtension === 'ts' ||
|
||||
fileExtension === 'jsx' ||
|
||||
fileExtension === 'tsx' ||
|
||||
fileExtension === 'java' ||
|
||||
fileExtension === 'cpp' ||
|
||||
fileExtension === 'c' ||
|
||||
fileExtension === 'php' ||
|
||||
fileExtension === 'html' ||
|
||||
fileExtension === 'css';
|
||||
|
||||
const hasMarkdownContent = !isCodeFile && previewContent && (
|
||||
previewContent.includes('# ') || // Headers
|
||||
previewContent.includes('## ') || // Headers
|
||||
previewContent.includes('**') || // Bold
|
||||
previewContent.includes('__') || // Bold
|
||||
previewContent.includes('- ') || // Lists
|
||||
previewContent.includes('* ') || // Lists
|
||||
previewContent.includes('1. ') || // Numbered lists
|
||||
previewContent.includes('```') || // Code blocks
|
||||
previewContent.includes('[') && previewContent.includes('](') // Links
|
||||
);
|
||||
|
||||
const isMarkdown = isMarkdownByType || (mimeType === 'text/plain' && hasMarkdownContent);
|
||||
|
||||
if (mimeType?.startsWith('image/')) {
|
||||
// Image preview
|
||||
const imageSrc = isBase64Encoded
|
||||
? `data:${mimeType};base64,${previewContent}`
|
||||
: previewContent;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={document.name}
|
||||
className={styles.image_preview}
|
||||
/>
|
||||
);
|
||||
} else if (fileExtension === 'pdf' || mimeType === 'application/pdf') {
|
||||
// PDF preview
|
||||
const pdfSrc = isBase64Encoded
|
||||
? `data:application/pdf;base64,${previewContent}`
|
||||
: previewContent;
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={pdfSrc}
|
||||
className={styles.pdf_preview}
|
||||
title={document.name}
|
||||
/>
|
||||
);
|
||||
} else if (isMarkdown) {
|
||||
// Markdown preview
|
||||
console.log('Rendering markdown with ReactMarkdown:', previewContent?.substring(0, 200));
|
||||
|
||||
return (
|
||||
<div className={styles.markdown_preview}>
|
||||
<ReactMarkdown>{previewContent}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
} else if (fileExtension === 'py') {
|
||||
// Python code preview
|
||||
return (
|
||||
<div className={styles.python_code_preview}>
|
||||
<div className={styles.code_header}>
|
||||
<span className={styles.code_language}>Python</span>
|
||||
<span className={styles.code_filename}>
|
||||
{document.ext ? `${document.name}.${document.ext}` : document.name}
|
||||
</span>
|
||||
</div>
|
||||
<pre className={styles.python_code_content}>
|
||||
<code>{previewContent}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
} else if (mimeType?.startsWith('text/') || fileExtension === 'txt') {
|
||||
// Enhanced text preview for all text files
|
||||
return (
|
||||
<div className={styles.enhanced_text_preview}>
|
||||
{previewContent?.split('\n').map((line, index) => {
|
||||
// Handle empty lines
|
||||
if (line.trim() === '') {
|
||||
return <div key={index} className={styles.text_line_break}></div>;
|
||||
}
|
||||
|
||||
// Check if line looks like a header (all caps, starts with numbers, etc.)
|
||||
const isHeader = line.match(/^[A-Z\s\d\.\-_]+:?\s*$/) && line.length < 80;
|
||||
const isNumberedItem = line.match(/^\s*\d+\.\s/);
|
||||
const isBulletItem = line.match(/^\s*[-*•]\s/);
|
||||
const isIndented = line.match(/^\s{4,}/);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`${styles.text_line} ${
|
||||
isHeader ? styles.text_header :
|
||||
isNumberedItem ? styles.text_numbered :
|
||||
isBulletItem ? styles.text_bullet :
|
||||
isIndented ? styles.text_indented : ''
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Code/raw text preview for non-text files
|
||||
return (
|
||||
<pre className={styles.code_preview}>
|
||||
{previewContent}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={handleBackdropClick}>
|
||||
<div className={styles.popup}>
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>
|
||||
{document.ext ? `${document.name}.${document.ext}` : document.name}
|
||||
</h3>
|
||||
<button
|
||||
className={styles.close_button}
|
||||
onClick={onClose}
|
||||
title="Close preview"
|
||||
>
|
||||
<MdClose />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{getPreviewComponent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreviewPopup;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { Prompt } from "../../../../hooks/usePrompts";
|
||||
import { FileInfo, useFileOperations } from "../../../../hooks/useFiles";
|
||||
import { useWorkflowOperations, useWorkflowMessages, useWorkflowStatus } from "../../../../hooks/useWorkflows";
|
||||
|
||||
interface UseChatLogicProps {
|
||||
|
|
@ -18,12 +19,14 @@ export const useChatLogic = ({
|
|||
const [inputValue, setInputValue] = useState("");
|
||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
||||
const [workflowCompleted, setWorkflowCompleted] = useState(false);
|
||||
const [attachedFiles, setAttachedFiles] = useState<FileInfo[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { startWorkflow, startingWorkflow, startError, stopWorkflow, stoppingWorkflows } = useWorkflowOperations();
|
||||
const { messages, loading: messagesLoading, error: messagesError, refetch: refetchMessages } = useWorkflowMessages(currentWorkflowId);
|
||||
const { status: workflowStatus, refetch: refetchStatus } = useWorkflowStatus(currentWorkflowId);
|
||||
const { handleFileUpload } = useFileOperations();
|
||||
|
||||
// Update input value when a prompt is selected
|
||||
useEffect(() => {
|
||||
|
|
@ -36,12 +39,12 @@ export const useChatLogic = ({
|
|||
}
|
||||
}, [selectedPrompt]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
// Auto-scroll to bottom when new messages arrive or workflow status changes
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages]);
|
||||
}, [messages, workflowCompleted, workflowStatus]);
|
||||
|
||||
// Polling logic for fetching messages and status
|
||||
useEffect(() => {
|
||||
|
|
@ -81,22 +84,65 @@ export const useChatLogic = ({
|
|||
setCurrentWorkflowId(resumeWorkflowId);
|
||||
setWorkflowCompleted(false);
|
||||
setInputValue("");
|
||||
setAttachedFiles([]);
|
||||
}
|
||||
}, [resumeWorkflowId, currentWorkflowId]);
|
||||
|
||||
const handleFileAttach = async (file: File) => {
|
||||
try {
|
||||
console.log('Uploading file:', file.name);
|
||||
const result = await handleFileUpload(file, currentWorkflowId || undefined);
|
||||
|
||||
if (result.success && result.fileData) {
|
||||
// Add the uploaded file to the attached files list
|
||||
const fileInfo: FileInfo = {
|
||||
id: result.fileData.id,
|
||||
name: result.fileData.name || file.name,
|
||||
mimeType: result.fileData.mimeType || file.type,
|
||||
size: result.fileData.size || file.size,
|
||||
creationDate: result.fileData.creationDate || new Date().toISOString(),
|
||||
workflowId: currentWorkflowId || undefined
|
||||
};
|
||||
|
||||
setAttachedFiles(prev => [...prev, fileInfo]);
|
||||
console.log('File uploaded successfully:', fileInfo);
|
||||
} else {
|
||||
console.error('Failed to upload file:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileRemove = (fileId: number) => {
|
||||
setAttachedFiles(prev => prev.filter(file => file.id !== fileId));
|
||||
};
|
||||
|
||||
const handleFilesSelect = (selectedFiles: FileInfo[]) => {
|
||||
// Add selected files to the attached files list, avoiding duplicates
|
||||
setAttachedFiles(prev => {
|
||||
const existingIds = new Set(prev.map(f => f.id));
|
||||
const newFiles = selectedFiles.filter(f => !existingIds.has(f.id));
|
||||
return [...prev, ...newFiles];
|
||||
});
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (inputValue.trim()) {
|
||||
console.log('Sending message:', inputValue);
|
||||
if (inputValue.trim() || attachedFiles.length > 0) {
|
||||
console.log('Sending message:', inputValue, 'with files:', attachedFiles.map(f => f.id));
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
// Prepare file IDs for the request
|
||||
const listFileId = attachedFiles.map(file => file.id);
|
||||
|
||||
// If we have a completed workflow, send as follow-up using the existing workflow ID
|
||||
if (workflowCompleted && currentWorkflowId) {
|
||||
console.log('Sending follow-up message to workflow:', currentWorkflowId);
|
||||
result = await startWorkflow({
|
||||
prompt: inputValue,
|
||||
listFileId: []
|
||||
prompt: inputValue || "Files attached", // Provide a default message if only files are sent
|
||||
listFileId: listFileId
|
||||
}, currentWorkflowId);
|
||||
|
||||
if (result.success) {
|
||||
|
|
@ -113,8 +159,8 @@ export const useChatLogic = ({
|
|||
setWorkflowCompleted(false);
|
||||
|
||||
result = await startWorkflow({
|
||||
prompt: inputValue,
|
||||
listFileId: []
|
||||
prompt: inputValue || "Files attached", // Provide a default message if only files are sent
|
||||
listFileId: listFileId
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
|
|
@ -126,8 +172,9 @@ export const useChatLogic = ({
|
|||
}
|
||||
|
||||
if (result.success) {
|
||||
// Clear the input after successful send
|
||||
// Clear the input and attached files after successful send
|
||||
setInputValue("");
|
||||
setAttachedFiles([]);
|
||||
// Call onPromptUsed if a prompt was used
|
||||
if (selectedPrompt && onPromptUsed) {
|
||||
onPromptUsed();
|
||||
|
|
@ -152,6 +199,7 @@ export const useChatLogic = ({
|
|||
setCurrentWorkflowId(null);
|
||||
setWorkflowCompleted(false);
|
||||
setInputValue("");
|
||||
setAttachedFiles([]);
|
||||
if (onWorkflowIdChange) {
|
||||
onWorkflowIdChange(null);
|
||||
}
|
||||
|
|
@ -187,6 +235,7 @@ export const useChatLogic = ({
|
|||
setInputValue,
|
||||
currentWorkflowId,
|
||||
workflowCompleted,
|
||||
attachedFiles,
|
||||
|
||||
// Refs
|
||||
inputRef,
|
||||
|
|
@ -205,6 +254,9 @@ export const useChatLogic = ({
|
|||
handleKeyPress,
|
||||
startNewWorkflow,
|
||||
handleStopWorkflow,
|
||||
handleFileAttach,
|
||||
handleFileRemove,
|
||||
handleFilesSelect,
|
||||
|
||||
// Workflow state
|
||||
isWorkflowRunning,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Prompt } from "../../../../hooks/usePrompts";
|
||||
import { FileInfo } from "../../../../hooks/useFiles";
|
||||
|
||||
export interface DashboardChatAreaProps {
|
||||
selectedPrompt?: Prompt | null;
|
||||
|
|
@ -8,11 +9,23 @@ export interface DashboardChatAreaProps {
|
|||
resumeWorkflowId?: string | null;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id?: string;
|
||||
fileId?: number;
|
||||
name: string;
|
||||
url?: string;
|
||||
type?: string;
|
||||
size?: number;
|
||||
downloadUrl?: string;
|
||||
ext?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id?: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
documents?: Document[];
|
||||
}
|
||||
|
||||
export interface WorkflowStatus {
|
||||
|
|
@ -31,6 +44,10 @@ export interface ChatInputProps {
|
|||
isWorkflowRunning: boolean;
|
||||
onStopWorkflow: () => void;
|
||||
isStoppingWorkflow: boolean;
|
||||
attachedFiles: FileInfo[];
|
||||
onFileAttach: (file: File) => void;
|
||||
onFileRemove: (fileId: number) => void;
|
||||
onFilesSelect: (files: FileInfo[]) => void;
|
||||
}
|
||||
|
||||
export interface MessageListProps {
|
||||
|
|
|
|||
142
src/components/Dateien/DateienAll.tsx
Normal file
142
src/components/Dateien/DateienAll.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import DateienItem from './DateienItem';
|
||||
import { UserFile } from '../../hooks/useFiles';
|
||||
import styles from './DateienLists.module.css';
|
||||
|
||||
// Sort types
|
||||
type SortField = 'file_name' | 'action' | 'size' | 'created_at' | 'source';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface DateienAllProps {
|
||||
files: UserFile[];
|
||||
onFileDeleted: () => void;
|
||||
onOptimisticDelete?: (fileId: number) => void;
|
||||
}
|
||||
|
||||
const DateienAll: React.FC<DateienAllProps> = ({ files, onFileDeleted, onOptimisticDelete }) => {
|
||||
const [sortField, setSortField] = useState<SortField>('created_at');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: SortField) => {
|
||||
if (field === sortField) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection(field === 'created_at' ? 'desc' : 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Sort all files
|
||||
const sortedFiles = [...files].sort((a, b) => {
|
||||
let result = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'file_name':
|
||||
result = a.file_name.localeCompare(b.file_name);
|
||||
break;
|
||||
case 'action':
|
||||
result = a.action.localeCompare(b.action);
|
||||
break;
|
||||
case 'size':
|
||||
const sizeA = a.size ?? 0;
|
||||
const sizeB = b.size ?? 0;
|
||||
result = sizeA - sizeB;
|
||||
break;
|
||||
case 'created_at':
|
||||
result = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
break;
|
||||
case 'source':
|
||||
result = a.source?.localeCompare(b.source ?? '') ?? 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
// Helper to render sort icon
|
||||
const renderSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <FaSort className={styles.sortIcon} />;
|
||||
return sortDirection === 'asc' ?
|
||||
<FaSortUp className={styles.sortIcon} /> :
|
||||
<FaSortDown className={styles.sortIcon} />;
|
||||
};
|
||||
|
||||
if (sortedFiles.length === 0) {
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.noFilesMessage}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<p>Keine Dateien gefunden.</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.filesTable}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Table Headers */}
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('file_name')}>
|
||||
<span>Name</span>
|
||||
{renderSortIcon('file_name')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('action')}>
|
||||
<span>Typ</span>
|
||||
{renderSortIcon('action')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('size')}>
|
||||
<span>Größe</span>
|
||||
{renderSortIcon('size')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('created_at')}>
|
||||
<span>Datum</span>
|
||||
{renderSortIcon('created_at')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files List */}
|
||||
<motion.ul
|
||||
className={styles.filesList}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedFiles.map((file: UserFile) => (
|
||||
<motion.div
|
||||
key={file.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
x: -50,
|
||||
transition: { duration: 0.2, ease: "easeIn" }
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
layout
|
||||
>
|
||||
<DateienItem
|
||||
file={file}
|
||||
onDelete={onFileDeleted}
|
||||
onOptimisticDelete={onOptimisticDelete ? () => onOptimisticDelete(file.id) : undefined}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateienAll;
|
||||
137
src/components/Dateien/DateienCreated.tsx
Normal file
137
src/components/Dateien/DateienCreated.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import DateienItem from './DateienItem';
|
||||
import { UserFile } from '../../hooks/useFiles';
|
||||
import styles from './DateienLists.module.css';
|
||||
|
||||
// Sort types
|
||||
type SortField = 'file_name' | 'action' | 'size' | 'created_at';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface DateienCreatedProps {
|
||||
files: UserFile[];
|
||||
onFileDeleted: () => void;
|
||||
onOptimisticDelete?: (fileId: number) => void;
|
||||
}
|
||||
|
||||
const DateienCreated: React.FC<DateienCreatedProps> = ({ files, onFileDeleted, onOptimisticDelete }) => {
|
||||
const [sortField, setSortField] = useState<SortField>('created_at');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
// Filter files for created (agent_created)
|
||||
const createdFiles = files.filter(file => file.source === 'agent_created');
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: SortField) => {
|
||||
if (field === sortField) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection(field === 'created_at' ? 'desc' : 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Sort files
|
||||
const sortedFiles = [...createdFiles].sort((a, b) => {
|
||||
let result = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'file_name':
|
||||
result = a.file_name.localeCompare(b.file_name);
|
||||
break;
|
||||
case 'action':
|
||||
result = a.action.localeCompare(b.action);
|
||||
break;
|
||||
case 'size':
|
||||
const sizeA = a.size ?? 0;
|
||||
const sizeB = b.size ?? 0;
|
||||
result = sizeA - sizeB;
|
||||
break;
|
||||
case 'created_at':
|
||||
result = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
// Helper to render sort icon
|
||||
const renderSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <FaSort className={styles.sortIcon} />;
|
||||
return sortDirection === 'asc' ?
|
||||
<FaSortUp className={styles.sortIcon} /> :
|
||||
<FaSortDown className={styles.sortIcon} />;
|
||||
};
|
||||
|
||||
if (sortedFiles.length === 0) {
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.noFilesMessage}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<p>Keine von der KI erstellten Dateien gefunden.</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.filesTable}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Table Headers */}
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('file_name')}>
|
||||
<span>Name</span>
|
||||
{renderSortIcon('file_name')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('action')}>
|
||||
<span>Typ</span>
|
||||
{renderSortIcon('action')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('size')}>
|
||||
<span>Größe</span>
|
||||
{renderSortIcon('size')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('created_at')}>
|
||||
<span>Datum</span>
|
||||
{renderSortIcon('created_at')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files List with AI-created indicator */}
|
||||
<motion.ul
|
||||
className={`${styles.filesList} ${styles.aiCreated}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedFiles.map((file: UserFile) => (
|
||||
<motion.div
|
||||
key={file.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
layout
|
||||
>
|
||||
<DateienItem
|
||||
file={file}
|
||||
onDelete={onFileDeleted}
|
||||
onOptimisticDelete={onOptimisticDelete ? () => onOptimisticDelete(file.id) : undefined}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateienCreated;
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
width: 900px;
|
||||
height: 700px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background-color: #f9fafb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabNavigation {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background-color: #f9fafb;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.tabButton {
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabButton:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.tabButton.active {
|
||||
color: var(--Brand-Green-Green, #3A8088);
|
||||
border-bottom-color: var(--Brand-Green-Green, #3A8088);
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.selectionControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.selectAllButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.selectAllButton:hover {
|
||||
background-color: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.selectionCount {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uploadButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: var(--Brand-Green-Green, #3A8088);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.uploadButton:hover {
|
||||
background-color: #2d6b73;
|
||||
}
|
||||
|
||||
.fileListContainer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.noFiles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.selectableFileList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selectableFileItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background-color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.selectableFileItem:hover {
|
||||
background-color: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.selectableFileItem.selected {
|
||||
background-color: #f0f8f8;
|
||||
border-color: var(--Brand-Green-Green, #3A8088);
|
||||
}
|
||||
|
||||
.fileCheckbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.checkedIcon {
|
||||
color: var(--Brand-Green-Green, #3A8088);
|
||||
}
|
||||
|
||||
.uncheckedIcon {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fileDetails {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background-color: #f9fafb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background-color: #ffffff;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancelButton:hover {
|
||||
background-color: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.confirmButton {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: var(--Brand-Green-Green, #3A8088);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.confirmButton:hover:not(:disabled) {
|
||||
background-color: #2d6b73;
|
||||
}
|
||||
|
||||
.confirmButton:disabled {
|
||||
background-color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.modal {
|
||||
width: 95vw;
|
||||
height: 85vh;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.tabNavigation {
|
||||
padding: 0 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tabButton {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.selectionControls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.fileListContainer {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.selectableFileItem {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
310
src/components/Dateien/DateienHinzufügen/DateienSelector.tsx
Normal file
310
src/components/Dateien/DateienHinzufügen/DateienSelector.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { IoClose, IoCheckbox, IoSquareOutline, IoCloudUploadOutline } from 'react-icons/io5';
|
||||
import { FaFile } from 'react-icons/fa';
|
||||
import { useUserFiles, UserFile, FileInfo } from '../../../hooks/useFiles';
|
||||
import DateienUploadTool from './DateienUploadTool';
|
||||
import DateienAll from '../DateienAll';
|
||||
import DateienUploads from '../DateienUploads';
|
||||
import DateienCreated from '../DateienCreated';
|
||||
import DateienShared from '../DateienShared';
|
||||
import styles from './DateienSelector.module.css';
|
||||
|
||||
type FileListType = 'all' | 'uploads' | 'created' | 'shared';
|
||||
|
||||
interface DateienSelectorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onFilesSelected: (files: FileInfo[]) => void;
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
const DateienSelector: React.FC<DateienSelectorProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onFilesSelected,
|
||||
allowMultiple = true
|
||||
}) => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState<FileListType>('all');
|
||||
const [showUploadTool, setShowUploadTool] = useState(false);
|
||||
const { files, loading, error, refetch } = useUserFiles();
|
||||
|
||||
// Filter files based on source
|
||||
const getFilteredFiles = (files: UserFile[], type: FileListType): UserFile[] => {
|
||||
switch (type) {
|
||||
case 'uploads':
|
||||
return files.filter(file => file.source === 'user_uploaded');
|
||||
case 'created':
|
||||
return files.filter(file => file.source === 'agent_created');
|
||||
case 'shared':
|
||||
return files.filter(file => file.source === 'shared_with_me');
|
||||
case 'all':
|
||||
default:
|
||||
return files;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFiles = getFilteredFiles(files, activeTab);
|
||||
|
||||
// Reset selection when tab changes
|
||||
useEffect(() => {
|
||||
setSelectedFiles(new Set());
|
||||
}, [activeTab]);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedFiles(new Set());
|
||||
setShowUploadTool(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleFileSelect = (fileId: number) => {
|
||||
if (allowMultiple) {
|
||||
setSelectedFiles(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(fileId)) {
|
||||
newSet.delete(fileId);
|
||||
} else {
|
||||
newSet.add(fileId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
setSelectedFiles(new Set([fileId]));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedFiles.size === filteredFiles.length) {
|
||||
setSelectedFiles(new Set());
|
||||
} else {
|
||||
setSelectedFiles(new Set(filteredFiles.map(file => file.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSelection = () => {
|
||||
const selectedFileObjects: FileInfo[] = files
|
||||
.filter(file => selectedFiles.has(file.id))
|
||||
.map(file => ({
|
||||
id: file.id,
|
||||
name: file.file_name,
|
||||
mimeType: deriveMimeTypeFromAction(file.action),
|
||||
size: file.size,
|
||||
creationDate: file.created_at,
|
||||
source: file.source
|
||||
}));
|
||||
|
||||
onFilesSelected(selectedFileObjects);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Helper function to derive MIME type from action (reverse mapping)
|
||||
const deriveMimeTypeFromAction = (action: string): string => {
|
||||
switch (action.toLowerCase()) {
|
||||
case 'bild':
|
||||
return 'image/jpeg'; // Default image type
|
||||
case 'pdf':
|
||||
return 'application/pdf';
|
||||
case 'dokument':
|
||||
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
case 'tabelle':
|
||||
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
case 'text':
|
||||
return 'text/plain';
|
||||
case 'video':
|
||||
return 'video/mp4'; // Default video type
|
||||
case 'audio':
|
||||
return 'audio/mpeg'; // Default audio type
|
||||
default:
|
||||
return 'application/octet-stream'; // Default binary type
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (file: File) => {
|
||||
// Refresh file list after upload
|
||||
refetch();
|
||||
setShowUploadTool(false);
|
||||
};
|
||||
|
||||
const renderFileListComponent = () => {
|
||||
const commonProps = {
|
||||
files: filteredFiles,
|
||||
onFileDeleted: refetch
|
||||
};
|
||||
|
||||
switch (activeTab) {
|
||||
case 'uploads':
|
||||
return <DateienUploads {...commonProps} />;
|
||||
case 'created':
|
||||
return <DateienCreated {...commonProps} />;
|
||||
case 'shared':
|
||||
return <DateienShared {...commonProps} />;
|
||||
case 'all':
|
||||
default:
|
||||
return <DateienAll {...commonProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTabLabel = (type: FileListType) => {
|
||||
const counts = {
|
||||
all: files.length,
|
||||
uploads: files.filter(f => f.source === 'user_uploaded').length,
|
||||
created: files.filter(f => f.source === 'agent_created').length,
|
||||
shared: files.filter(f => f.source === 'shared_with_me').length
|
||||
};
|
||||
|
||||
const labels = {
|
||||
all: 'Alle Dateien',
|
||||
uploads: 'Hochgeladen',
|
||||
created: 'KI-erstellt',
|
||||
shared: 'Geteilt'
|
||||
};
|
||||
|
||||
return `${labels[type]} (${counts[type]})`;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (showUploadTool) {
|
||||
return (
|
||||
<DateienUploadTool
|
||||
isOpen={true}
|
||||
onClose={() => setShowUploadTool(false)}
|
||||
onFileUpload={handleFileUpload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<motion.div
|
||||
className={styles.modal}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<h2>Dateien auswählen</h2>
|
||||
<button className={styles.closeButton} onClick={onClose}>
|
||||
<IoClose />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* Tab Navigation */}
|
||||
<div className={styles.tabNavigation}>
|
||||
{(['all', 'uploads', 'created', 'shared'] as FileListType[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`${styles.tabButton} ${activeTab === tab ? styles.active : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{getTabLabel(tab)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Bar */}
|
||||
<div className={styles.actionBar}>
|
||||
<div className={styles.selectionControls}>
|
||||
{allowMultiple && filteredFiles.length > 0 && (
|
||||
<button
|
||||
className={styles.selectAllButton}
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{selectedFiles.size === filteredFiles.length ? (
|
||||
<>
|
||||
<IoCheckbox /> Alle abwählen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IoSquareOutline /> Alle auswählen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{selectedFiles.size > 0 && (
|
||||
<span className={styles.selectionCount}>
|
||||
{selectedFiles.size} Datei{selectedFiles.size !== 1 ? 'en' : ''} ausgewählt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.uploadButton}
|
||||
onClick={() => setShowUploadTool(true)}
|
||||
>
|
||||
<IoCloudUploadOutline />
|
||||
Neue Datei hochladen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className={styles.fileListContainer}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>Dateien werden geladen...</div>
|
||||
) : error ? (
|
||||
<div className={styles.error}>Fehler beim Laden der Dateien: {error}</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className={styles.noFiles}>Keine Dateien in dieser Kategorie gefunden.</div>
|
||||
) : (
|
||||
<div className={styles.selectableFileList}>
|
||||
{filteredFiles.map(file => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`${styles.selectableFileItem} ${
|
||||
selectedFiles.has(file.id) ? styles.selected : ''
|
||||
}`}
|
||||
onClick={() => handleFileSelect(file.id)}
|
||||
>
|
||||
<div className={styles.fileCheckbox}>
|
||||
{selectedFiles.has(file.id) ? (
|
||||
<IoCheckbox className={styles.checkedIcon} />
|
||||
) : (
|
||||
<IoSquareOutline className={styles.uncheckedIcon} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.fileIcon}>
|
||||
<FaFile />
|
||||
</div>
|
||||
<div className={styles.fileInfo}>
|
||||
<div className={styles.fileName}>{file.file_name}</div>
|
||||
<div className={styles.fileDetails}>
|
||||
{file.action} • {file.size ? `${Math.round(file.size / 1024)} KB` : 'Unbekannte Größe'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={styles.footer}>
|
||||
<button
|
||||
className={styles.cancelButton}
|
||||
onClick={onClose}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className={styles.confirmButton}
|
||||
onClick={handleConfirmSelection}
|
||||
disabled={selectedFiles.size === 0}
|
||||
>
|
||||
{selectedFiles.size === 0
|
||||
? 'Dateien auswählen'
|
||||
: `${selectedFiles.size} Datei${selectedFiles.size !== 1 ? 'en' : ''} hinzufügen`
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateienSelector;
|
||||
|
|
@ -11,25 +11,46 @@
|
|||
z-index: 1000;
|
||||
}
|
||||
|
||||
.h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
font-family: 'Arial', sans-serif;
|
||||
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
padding: 35px 40px 30px 40px;
|
||||
border-radius: 30px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modalHeader h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
|
|
@ -43,27 +64,27 @@
|
|||
|
||||
.uploadStatus {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uploadStatus.success {
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
color: #388e3c;
|
||||
border: 1px solid #4caf50;
|
||||
background-color: #e6f2f2;
|
||||
color: #3a8088;
|
||||
border: 1px solid #3a8088;
|
||||
}
|
||||
|
||||
.uploadStatus.error {
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
color: #d32f2f;
|
||||
border: 1px solid #f44336;
|
||||
background-color: #fceff0;
|
||||
color: #d85d67;
|
||||
border: 1px solid #d85d67;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 4px;
|
||||
border-radius: 15px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
|
|
@ -72,13 +93,13 @@
|
|||
}
|
||||
|
||||
.dropzone.active {
|
||||
border-color: #2196f3;
|
||||
background-color: rgba(33, 150, 243, 0.1);
|
||||
border-color: #3a8088;
|
||||
background-color: #e6f2f2;
|
||||
}
|
||||
|
||||
.dropzone.uploading {
|
||||
border-color: #4caf50;
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
border-color: #3a8088;
|
||||
background-color: #e6f2f2;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
|
|
@ -97,46 +118,43 @@
|
|||
}
|
||||
|
||||
.browseButton {
|
||||
background-color: #2196f3;
|
||||
background-color: #3a8088;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
border-radius: 15px;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
}
|
||||
|
||||
.browseButton:hover {
|
||||
background-color: #1976d2;
|
||||
background-color: #34737b;
|
||||
}
|
||||
|
||||
.browseButton:disabled {
|
||||
background-color: #90caf9;
|
||||
background-color: #f4f3f5;
|
||||
color: #888098;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.selectedFile {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
.uploadButton {
|
||||
background-color: #4caf50;
|
||||
background-color: #3a8088;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
border-radius: 15px;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
}
|
||||
|
||||
.uploadButton:hover {
|
||||
background-color: #388e3c;
|
||||
background-color: #34737b;
|
||||
}
|
||||
|
||||
.uploadButton:disabled {
|
||||
background-color: #a5d6a7;
|
||||
background-color: #f4f3f5;
|
||||
color: #888098;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import styles from './DateienUpload.module.css';
|
||||
import styles from './DateienUploadTool.module.css';
|
||||
import { IoCloudUploadOutline } from "react-icons/io5";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { useFileOperations } from '../../../hooks/useFiles';
|
||||
|
||||
interface DateienUploadProps {
|
||||
interface DateienUploadToolProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onFileUpload: (file: File) => void;
|
||||
}
|
||||
|
||||
function DateienUpload({ isOpen, onClose, onFileUpload }: DateienUploadProps) {
|
||||
function DateienUploadTool({ isOpen, onClose, onFileUpload }: DateienUploadToolProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
|
@ -71,10 +71,12 @@ function DateienUpload({ isOpen, onClose, onFileUpload }: DateienUploadProps) {
|
|||
return (
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2>Datei hochladen</h2>
|
||||
<button className={styles.closeButton} onClick={onClose} disabled={isUploading}>
|
||||
<IoClose />
|
||||
</button>
|
||||
<h2>Datei hochladen</h2>
|
||||
</div>
|
||||
|
||||
{uploadStatus && (
|
||||
<div className={`${styles.uploadStatus} ${uploadStatus.success ? styles.success : styles.error}`}>
|
||||
|
|
@ -120,4 +122,4 @@ function DateienUpload({ isOpen, onClose, onFileUpload }: DateienUploadProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export default DateienUpload;
|
||||
export default DateienUploadTool;
|
||||
|
|
@ -14,13 +14,12 @@
|
|||
|
||||
/* Column layout matching the header structure */
|
||||
.fileName {
|
||||
flex: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
padding-right: 8px;
|
||||
padding-left: 14px; /* Align with table header */
|
||||
}
|
||||
|
||||
.fileName span {
|
||||
|
|
@ -31,16 +30,24 @@
|
|||
}
|
||||
|
||||
.fileType {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fileSource {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.fileSize {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
|
|
@ -56,12 +63,12 @@
|
|||
}
|
||||
|
||||
.fileDate {
|
||||
flex: 1.5;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
|
@ -71,14 +78,15 @@
|
|||
}
|
||||
|
||||
.actionButtons {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.downloadButton,
|
||||
.deleteButton {
|
||||
.deleteButton,
|
||||
.previewButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -94,7 +102,8 @@
|
|||
}
|
||||
|
||||
.downloadButton:hover:not(:disabled),
|
||||
.deleteButton:hover:not(:disabled) {
|
||||
.deleteButton:hover:not(:disabled),
|
||||
.previewButton:hover:not(:disabled) {
|
||||
background-color: var(--background-color-hover, #e8e8e8);
|
||||
color: var(--text-color-primary, #333);
|
||||
}
|
||||
|
|
@ -103,6 +112,10 @@
|
|||
color: #dc2626;
|
||||
}
|
||||
|
||||
.previewButton:hover:not(:disabled) {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.deleteButton.confirm {
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
|
|
@ -113,7 +126,8 @@
|
|||
}
|
||||
|
||||
.downloadButton:disabled,
|
||||
.deleteButton:disabled {
|
||||
.deleteButton:disabled,
|
||||
.previewButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { FaFile, FaDownload, FaTrash } from "react-icons/fa";
|
||||
import { MdOutlineRemoveRedEye } from "react-icons/md";
|
||||
import styles from "./DateienItem.module.css";
|
||||
import { useState } from "react";
|
||||
import { useFileOperations } from "../../hooks/useFiles";
|
||||
import FilePreviewPopup from "../Dashboard/DashboardChat/DashboardChatArea/FilePreviewPopup";
|
||||
import { Document } from "../Dashboard/DashboardChat/DashboardChatArea/dashboardChatAreaTypes";
|
||||
|
||||
type DateienItemProps = {
|
||||
file: {
|
||||
|
|
@ -10,8 +13,10 @@ type DateienItemProps = {
|
|||
action: string;
|
||||
created_at: string;
|
||||
size?: number;
|
||||
source?: string; // 'user_uploaded', 'agent_created', or 'shared_with_me'
|
||||
};
|
||||
onDelete?: () => void;
|
||||
onOptimisticDelete?: () => void; // New prop for immediate UI update
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -30,9 +35,27 @@ const formatFileSize = (bytes?: number): string => {
|
|||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const DateienItem = ({ file, onDelete }: DateienItemProps) => {
|
||||
/**
|
||||
* Formats the file source for display
|
||||
*/
|
||||
const formatFileSource = (source?: string): string => {
|
||||
switch (source) {
|
||||
case 'user_uploaded':
|
||||
return 'Hochgeladen';
|
||||
case 'agent_created':
|
||||
return 'KI-erstellt';
|
||||
case 'shared_with_me':
|
||||
return 'Geteilt';
|
||||
default:
|
||||
return 'Unbekannt';
|
||||
}
|
||||
};
|
||||
|
||||
const DateienItem = ({ file, onDelete, onOptimisticDelete }: DateienItemProps) => {
|
||||
const { downloadingFiles, deletingFiles, handleFileDownload, handleFileDelete } = useFileOperations();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [previewDocument, setPreviewDocument] = useState<Document | null>(null);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const isDownloading = downloadingFiles.has(file.id);
|
||||
const isDeleting = deletingFiles.has(file.id);
|
||||
|
||||
|
|
@ -58,10 +81,13 @@ const DateienItem = ({ file, onDelete }: DateienItemProps) => {
|
|||
|
||||
const handleDeleteClick = async () => {
|
||||
if (showDeleteConfirm) {
|
||||
const success = await handleFileDelete(file.id);
|
||||
if (success && onDelete) {
|
||||
const success = await handleFileDelete(file.id, onOptimisticDelete);
|
||||
if (!success) {
|
||||
// If deletion failed, refresh the file list to restore the file
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
setShowDeleteConfirm(false);
|
||||
} else {
|
||||
setShowDeleteConfirm(true);
|
||||
|
|
@ -72,7 +98,32 @@ const DateienItem = ({ file, onDelete }: DateienItemProps) => {
|
|||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
// Split filename to get name and extension
|
||||
const nameParts = file.file_name.split('.');
|
||||
const extension = nameParts.length > 1 ? nameParts.pop() : undefined;
|
||||
const fileName = nameParts.join('.');
|
||||
|
||||
// Create a Document object compatible with FilePreviewPopup
|
||||
const document: Document = {
|
||||
id: String(file.id),
|
||||
fileId: file.id,
|
||||
name: fileName,
|
||||
ext: extension,
|
||||
size: file.size
|
||||
};
|
||||
|
||||
setPreviewDocument(document);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewDocument(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<li>
|
||||
{/* 1st column: Name with icon */}
|
||||
<div className={styles.fileName}>
|
||||
|
|
@ -80,9 +131,10 @@ const DateienItem = ({ file, onDelete }: DateienItemProps) => {
|
|||
<span>{file.file_name}</span>
|
||||
</div>
|
||||
|
||||
{/* 2nd column: Type */}
|
||||
{/* 2nd column: Type with source */}
|
||||
<div className={styles.fileType}>
|
||||
{file.action}
|
||||
<div>{file.action}</div>
|
||||
<div className={styles.fileSource}>{formatFileSource(file.source)}</div>
|
||||
</div>
|
||||
|
||||
{/* 3rd column: Size */}
|
||||
|
|
@ -97,6 +149,14 @@ const DateienItem = ({ file, onDelete }: DateienItemProps) => {
|
|||
</span>
|
||||
|
||||
<div className={styles.actionButtons}>
|
||||
<button
|
||||
className={styles.previewButton}
|
||||
onClick={handlePreview}
|
||||
disabled={isDownloading || isDeleting}
|
||||
title="Preview file"
|
||||
>
|
||||
<MdOutlineRemoveRedEye className={styles.actionIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.downloadButton} ${isDownloading ? styles.downloading : ''}`}
|
||||
onClick={() => handleFileDownload(file.id, file.file_name)}
|
||||
|
|
@ -120,6 +180,16 @@ const DateienItem = ({ file, onDelete }: DateienItemProps) => {
|
|||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{/* File Preview Popup */}
|
||||
{previewDocument && (
|
||||
<FilePreviewPopup
|
||||
document={previewDocument}
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={handleClosePreview}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
100
src/components/Dateien/DateienLists.module.css
Normal file
100
src/components/Dateien/DateienLists.module.css
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/* No files message */
|
||||
.noFilesMessage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-color-secondary, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Files table container */
|
||||
.filesTable {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Table header with exact grid positioning */
|
||||
.tableHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 45% 15% 15% 25%;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0px 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #f1f1f1;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Header cells with exact positioning */
|
||||
.headerCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
padding-left: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.headerCell:hover {
|
||||
color: var(--Grayscale-Black, #24262B);
|
||||
}
|
||||
|
||||
/* Adjust first column for icon space */
|
||||
.headerCell:nth-child(1) {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
/* Simple sort icon styling */
|
||||
.sortIcon {
|
||||
margin-left: 6px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.headerCell:hover .sortIcon {
|
||||
color: var(--Grayscale-Black, #24262B);
|
||||
}
|
||||
|
||||
/* File list styling */
|
||||
.filesList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Override the flex layout in DateienItem to force matching the header */
|
||||
.filesList li {
|
||||
display: grid !important;
|
||||
grid-template-columns: 45% 15% 15% 25%;
|
||||
border-bottom: 1px solid #f1f1f1;
|
||||
height: 60px;
|
||||
padding: 0 16px;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filesList li:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
|
||||
137
src/components/Dateien/DateienShared.tsx
Normal file
137
src/components/Dateien/DateienShared.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import DateienItem from './DateienItem';
|
||||
import { UserFile } from '../../hooks/useFiles';
|
||||
import styles from './DateienLists.module.css';
|
||||
|
||||
// Sort types
|
||||
type SortField = 'file_name' | 'action' | 'size' | 'created_at';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface DateienSharedProps {
|
||||
files: UserFile[];
|
||||
onFileDeleted: () => void;
|
||||
onOptimisticDelete?: (fileId: number) => void;
|
||||
}
|
||||
|
||||
const DateienShared: React.FC<DateienSharedProps> = ({ files, onFileDeleted, onOptimisticDelete }) => {
|
||||
const [sortField, setSortField] = useState<SortField>('created_at');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
// Filter files for shared (shared_with_me)
|
||||
const sharedFiles = files.filter(file => file.source === 'shared_with_me');
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: SortField) => {
|
||||
if (field === sortField) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection(field === 'created_at' ? 'desc' : 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Sort files
|
||||
const sortedFiles = [...sharedFiles].sort((a, b) => {
|
||||
let result = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'file_name':
|
||||
result = a.file_name.localeCompare(b.file_name);
|
||||
break;
|
||||
case 'action':
|
||||
result = a.action.localeCompare(b.action);
|
||||
break;
|
||||
case 'size':
|
||||
const sizeA = a.size ?? 0;
|
||||
const sizeB = b.size ?? 0;
|
||||
result = sizeA - sizeB;
|
||||
break;
|
||||
case 'created_at':
|
||||
result = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
// Helper to render sort icon
|
||||
const renderSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <FaSort className={styles.sortIcon} />;
|
||||
return sortDirection === 'asc' ?
|
||||
<FaSortUp className={styles.sortIcon} /> :
|
||||
<FaSortDown className={styles.sortIcon} />;
|
||||
};
|
||||
|
||||
if (sortedFiles.length === 0) {
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.noFilesMessage}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<p>Keine mit Ihnen geteilten Dateien gefunden.</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.filesTable}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Table Headers */}
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('file_name')}>
|
||||
<span>Name</span>
|
||||
{renderSortIcon('file_name')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('action')}>
|
||||
<span>Typ</span>
|
||||
{renderSortIcon('action')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('size')}>
|
||||
<span>Größe</span>
|
||||
{renderSortIcon('size')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('created_at')}>
|
||||
<span>Datum</span>
|
||||
{renderSortIcon('created_at')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files List with shared indicator */}
|
||||
<motion.ul
|
||||
className={`${styles.filesList} ${styles.shared}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedFiles.map((file: UserFile) => (
|
||||
<motion.div
|
||||
key={file.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
layout
|
||||
>
|
||||
<DateienItem
|
||||
file={file}
|
||||
onDelete={onFileDeleted}
|
||||
onOptimisticDelete={onOptimisticDelete ? () => onOptimisticDelete(file.id) : undefined}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateienShared;
|
||||
137
src/components/Dateien/DateienUploads.tsx
Normal file
137
src/components/Dateien/DateienUploads.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import DateienItem from './DateienItem';
|
||||
import { UserFile } from '../../hooks/useFiles';
|
||||
import styles from './DateienLists.module.css';
|
||||
|
||||
// Sort types
|
||||
type SortField = 'file_name' | 'action' | 'size' | 'created_at';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface DateienUploadsProps {
|
||||
files: UserFile[];
|
||||
onFileDeleted: () => void;
|
||||
onOptimisticDelete?: (fileId: number) => void;
|
||||
}
|
||||
|
||||
const DateienUploads: React.FC<DateienUploadsProps> = ({ files, onFileDeleted, onOptimisticDelete }) => {
|
||||
const [sortField, setSortField] = useState<SortField>('created_at');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
// Filter files for uploads (user_uploaded)
|
||||
const uploadedFiles = files.filter(file => file.source === 'user_uploaded');
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: SortField) => {
|
||||
if (field === sortField) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection(field === 'created_at' ? 'desc' : 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Sort files
|
||||
const sortedFiles = [...uploadedFiles].sort((a, b) => {
|
||||
let result = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'file_name':
|
||||
result = a.file_name.localeCompare(b.file_name);
|
||||
break;
|
||||
case 'action':
|
||||
result = a.action.localeCompare(b.action);
|
||||
break;
|
||||
case 'size':
|
||||
const sizeA = a.size ?? 0;
|
||||
const sizeB = b.size ?? 0;
|
||||
result = sizeA - sizeB;
|
||||
break;
|
||||
case 'created_at':
|
||||
result = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
// Helper to render sort icon
|
||||
const renderSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <FaSort className={styles.sortIcon} />;
|
||||
return sortDirection === 'asc' ?
|
||||
<FaSortUp className={styles.sortIcon} /> :
|
||||
<FaSortDown className={styles.sortIcon} />;
|
||||
};
|
||||
|
||||
if (sortedFiles.length === 0) {
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.noFilesMessage}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<p>Keine hochgeladenen Dateien gefunden.</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.filesTable}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Table Headers */}
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('file_name')}>
|
||||
<span>Name</span>
|
||||
{renderSortIcon('file_name')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('action')}>
|
||||
<span>Typ</span>
|
||||
{renderSortIcon('action')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('size')}>
|
||||
<span>Größe</span>
|
||||
{renderSortIcon('size')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('created_at')}>
|
||||
<span>Datum</span>
|
||||
{renderSortIcon('created_at')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files List */}
|
||||
<motion.ul
|
||||
className={styles.filesList}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedFiles.map((file: UserFile) => (
|
||||
<motion.div
|
||||
key={file.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
layout
|
||||
>
|
||||
<DateienItem
|
||||
file={file}
|
||||
onDelete={onFileDeleted}
|
||||
onOptimisticDelete={onOptimisticDelete ? () => onOptimisticDelete(file.id) : undefined}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateienUploads;
|
||||
|
|
@ -12,6 +12,7 @@ export interface FileInfo {
|
|||
mandateId?: number;
|
||||
userId?: number;
|
||||
workflowId?: string;
|
||||
source?: string; // 'user_uploaded', 'agent_created', or 'shared_with_me'
|
||||
}
|
||||
|
||||
export interface UserFile {
|
||||
|
|
@ -20,6 +21,7 @@ export interface UserFile {
|
|||
action: string;
|
||||
created_at: string;
|
||||
size?: number;
|
||||
source?: string; // 'user_uploaded', 'agent_created', or 'shared_with_me'
|
||||
}
|
||||
|
||||
// Files list hook
|
||||
|
|
@ -74,7 +76,8 @@ export function useUserFiles() {
|
|||
file_name: apiFile.name,
|
||||
action: action,
|
||||
created_at: apiFile.creationDate,
|
||||
size: apiFile.size
|
||||
size: apiFile.size,
|
||||
source: apiFile.source
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -84,11 +87,28 @@ export function useUserFiles() {
|
|||
}
|
||||
};
|
||||
|
||||
// Optimistically remove a file from the local state
|
||||
const removeFileOptimistically = (fileId: number) => {
|
||||
setFiles(prevFiles => prevFiles.filter(file => file.id !== fileId));
|
||||
};
|
||||
|
||||
// Add a file to the local state (for when upload completes)
|
||||
const addFileOptimistically = (newFile: UserFile) => {
|
||||
setFiles(prevFiles => [newFile, ...prevFiles]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
}, []);
|
||||
|
||||
return { files, loading, error, refetch: fetchFiles };
|
||||
return {
|
||||
files,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchFiles,
|
||||
removeFileOptimistically,
|
||||
addFileOptimistically
|
||||
};
|
||||
}
|
||||
|
||||
// File operations hook
|
||||
|
|
@ -136,10 +156,15 @@ export function useFileOperations() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleFileDelete = async (fileId: number) => {
|
||||
const handleFileDelete = async (fileId: number, onOptimisticDelete?: () => void) => {
|
||||
setDeleteError(null);
|
||||
setDeletingFiles(prev => new Set(prev).add(fileId));
|
||||
|
||||
// Optimistically remove from UI if callback provided
|
||||
if (onOptimisticDelete) {
|
||||
onOptimisticDelete();
|
||||
}
|
||||
|
||||
try {
|
||||
await request({
|
||||
url: `/api/files/${fileId}`,
|
||||
|
|
@ -151,6 +176,7 @@ export function useFileOperations() {
|
|||
return true;
|
||||
} catch (error: any) {
|
||||
setDeleteError(error.message);
|
||||
// If deletion failed and we optimistically removed it, we should refetch to restore the file
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingFiles(prev => {
|
||||
|
|
|
|||
|
|
@ -156,7 +156,11 @@ export function useWorkflowStatus(workflowId: string | null) {
|
|||
const { request, isLoading: loading, error } = useApiRequest<null, Workflow>();
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!workflowId) return;
|
||||
if (!workflowId) {
|
||||
// Clear status when no workflow is selected
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await request({
|
||||
|
|
@ -183,7 +187,11 @@ export function useWorkflowMessages(workflowId: string | null, messageId?: strin
|
|||
const { request, isLoading: loading, error } = useApiRequest<null, WorkflowMessage[]>();
|
||||
|
||||
const fetchMessages = async () => {
|
||||
if (!workflowId) return;
|
||||
if (!workflowId) {
|
||||
// Clear messages when no workflow is selected
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await request({
|
||||
|
|
@ -232,3 +240,95 @@ export function useWorkflowLogs(workflowId: string | null, logId?: string) {
|
|||
|
||||
return { logs, loading, error, refetch: fetchLogs };
|
||||
}
|
||||
|
||||
// File preview hook
|
||||
export function useFilePreview() {
|
||||
const [previewContent, setPreviewContent] = useState<string | null>(null);
|
||||
const [fileMetadata, setFileMetadata] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { request } = useApiRequest();
|
||||
|
||||
const fetchPreview = async (fileId: string | number) => {
|
||||
console.log('fetchPreview called with fileId:', fileId, 'type:', typeof fileId);
|
||||
|
||||
if (!fileId) {
|
||||
setError("File ID not available");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setPreviewContent(null);
|
||||
setFileMetadata(null);
|
||||
|
||||
try {
|
||||
// Convert fileId to number since backend expects integer
|
||||
let numericFileId: number;
|
||||
|
||||
if (typeof fileId === 'number') {
|
||||
numericFileId = fileId;
|
||||
} else {
|
||||
numericFileId = parseInt(String(fileId), 10);
|
||||
}
|
||||
|
||||
console.log('Parsed fileId:', numericFileId, 'isNaN:', isNaN(numericFileId));
|
||||
|
||||
if (isNaN(numericFileId)) {
|
||||
throw new Error(`Invalid file ID format: "${fileId}" (type: ${typeof fileId}). Expected a numeric file ID, but got a document UUID. Make sure the document object has a 'fileId' property with the numeric file ID.`);
|
||||
}
|
||||
|
||||
console.log('Making API request to:', `/api/workflows/files/${numericFileId}/preview`);
|
||||
|
||||
const response = await request({
|
||||
url: `/api/workflows/files/${numericFileId}/preview`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
console.log('API response:', response);
|
||||
|
||||
// Handle response as object with metadata and preview content
|
||||
if (typeof response === 'object' && response !== null) {
|
||||
setFileMetadata(response);
|
||||
// Try different possible property names for the content
|
||||
const content = response.preview || response.content || response.data || response.previewContent;
|
||||
|
||||
// Debug for PDF issues only
|
||||
if (response.mimeType === 'application/pdf') {
|
||||
console.log('PDF Preview Debug:', {
|
||||
hasPreview: !!response.preview,
|
||||
previewLength: response.preview?.length,
|
||||
hasBase64Flag: response.base64Encoded,
|
||||
mimeType: response.mimeType
|
||||
});
|
||||
}
|
||||
|
||||
setPreviewContent(content || null);
|
||||
} else {
|
||||
// Fallback if response is just the content
|
||||
setPreviewContent(response);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('File preview error:', err);
|
||||
setError(err.message || "Failed to load preview");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearPreview = () => {
|
||||
setPreviewContent(null);
|
||||
setFileMetadata(null);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return {
|
||||
previewContent,
|
||||
fileMetadata,
|
||||
isLoading,
|
||||
error,
|
||||
fetchPreview,
|
||||
clearPreview
|
||||
};
|
||||
}
|
||||
|
|
@ -14,26 +14,28 @@
|
|||
}
|
||||
|
||||
.horizontalLineLight {
|
||||
width: 100%;
|
||||
width: calc(100% + 60px);
|
||||
background-color: #F1F1F1;
|
||||
height: 1px;
|
||||
margin-top: 90px;
|
||||
margin-left: -30px;
|
||||
position: absolute;
|
||||
margin-bottom: 0;
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
/* Combined Header with Tabs and Add Button */
|
||||
.combinedHeader {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: flex-start;
|
||||
height: 62px;
|
||||
color: var(--Grayscale-Black, #24262B);
|
||||
padding-top: 30px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
min-height: 62px;
|
||||
}
|
||||
|
||||
.datei_hinzufügen_button {
|
||||
border-radius: 30px;
|
||||
background: var(--Grayscale-Gray, #E9E9E9);
|
||||
background: #3a8080;
|
||||
color: #fff;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: left;
|
||||
|
|
@ -44,83 +46,77 @@
|
|||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.datei_hinzufügen_button:hover {
|
||||
cursor: pointer;
|
||||
background-color: #34737b;
|
||||
}
|
||||
|
||||
.add_icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Files table container */
|
||||
.filesTable {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
/* Tab Navigation Styles */
|
||||
.tabButtonDiv {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tabButtonWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Table header with exact grid positioning to match the screenshot */
|
||||
.tableHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 45% 15% 15% 25%;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0px 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Header cells with exact positioning */
|
||||
.headerCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.tabButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 20px 0px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
padding-left: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Adjust first column for icon space */
|
||||
.headerCell:nth-child(1) {
|
||||
padding-left: 30px;
|
||||
.tabButtonActive {
|
||||
color: var(--Grayscale-Black, #24262B);
|
||||
}
|
||||
|
||||
/* Simple sort icon styling */
|
||||
.sortIcon {
|
||||
margin-left: 6px;
|
||||
font-size: 14px;
|
||||
.tabButtonInactive {
|
||||
color: #A0A0A0;
|
||||
}
|
||||
|
||||
/* Modify the file list to use the same grid layout */
|
||||
.filesList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
.tabButtonInactive:hover {
|
||||
background-color: var(--background-color-hover, #f5f5f5);
|
||||
color: var(--text-color-primary, #333);
|
||||
}
|
||||
|
||||
/* Override the flex layout in DateienItem to force matching the header */
|
||||
.filesList li {
|
||||
display: grid !important;
|
||||
grid-template-columns: 45% 15% 15% 25%;
|
||||
border-bottom: 1px solid #f1f1f1;
|
||||
height: 60px;
|
||||
padding: 0 16px;
|
||||
align-items: center;
|
||||
.tabUnderline {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
background-color: var(--Grayscale-Black, #24262B);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
margin: 1rem 0;
|
||||
padding: 0.5rem;
|
||||
background-color: #ffebee;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
/* Content area */
|
||||
.contentArea {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 300px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -1,30 +1,30 @@
|
|||
import styles from './Dateien.module.css'
|
||||
import { IoAddCircleOutline } from "react-icons/io5";
|
||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||
import DateienItem from '../../components/Dateien/DateienItem';
|
||||
import DateienUpload from './DateienHinzufügen/DateienUpload';
|
||||
import DateienUpload from '../../components/Dateien/DateienHinzufügen/DateienUploadTool';
|
||||
import DateienAll from '../../components/Dateien/DateienAll';
|
||||
import DateienUploads from '../../components/Dateien/DateienUploads';
|
||||
import DateienCreated from '../../components/Dateien/DateienCreated';
|
||||
import DateienShared from '../../components/Dateien/DateienShared';
|
||||
import { useState } from 'react';
|
||||
import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
// Define the file type interface
|
||||
interface UserFile {
|
||||
id: number;
|
||||
file_name: string;
|
||||
action: string;
|
||||
created_at: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// Sort types
|
||||
type SortField = 'file_name' | 'action' | 'size' | 'created_at';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
// Tab types
|
||||
type TabType = 'alle' | 'uploads' | 'erstellt' | 'geteilt';
|
||||
|
||||
function Dateien() {
|
||||
const { files, loading, error, refetch } = useUserFiles();
|
||||
const { files, loading, error, refetch, removeFileOptimistically } = useUserFiles();
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const { uploadError, downloadError, deleteError } = useFileOperations();
|
||||
const [sortField, setSortField] = useState<SortField>('created_at');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [activeTab, setActiveTab] = useState<TabType>('alle');
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ key: 'alle' as TabType, label: 'Alle Dateien' },
|
||||
{ key: 'uploads' as TabType, label: 'Meine Uploads' },
|
||||
{ key: 'erstellt' as TabType, label: 'Erstellte Dateien' },
|
||||
{ key: 'geteilt' as TabType, label: 'Geteilte Dateien' }
|
||||
];
|
||||
|
||||
// Single function to handle file refresh
|
||||
const refreshFiles = () => {
|
||||
|
|
@ -48,55 +48,65 @@ function Dateien() {
|
|||
refreshFiles();
|
||||
};
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: SortField) => {
|
||||
if (field === sortField) {
|
||||
// Toggle direction if same field is clicked
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
// Set new field and default to ascending for most fields, descending for dates
|
||||
setSortField(field);
|
||||
setSortDirection(field === 'created_at' ? 'desc' : 'asc');
|
||||
}
|
||||
// Render the appropriate component based on active tab
|
||||
const renderActiveTabContent = () => {
|
||||
const commonProps = {
|
||||
files,
|
||||
onFileDeleted: handleFileDeleted,
|
||||
onOptimisticDelete: removeFileOptimistically
|
||||
};
|
||||
|
||||
// Sort files
|
||||
const sortedFiles = [...files].sort((a, b) => {
|
||||
let result = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'file_name':
|
||||
result = a.file_name.localeCompare(b.file_name);
|
||||
break;
|
||||
case 'action':
|
||||
result = a.action.localeCompare(b.action);
|
||||
break;
|
||||
case 'size':
|
||||
// Handle undefined sizes gracefully
|
||||
const sizeA = a.size ?? 0;
|
||||
const sizeB = b.size ?? 0;
|
||||
result = sizeA - sizeB;
|
||||
break;
|
||||
case 'created_at':
|
||||
result = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
break;
|
||||
switch (activeTab) {
|
||||
case 'alle':
|
||||
return <DateienAll {...commonProps} />;
|
||||
case 'uploads':
|
||||
return <DateienUploads {...commonProps} />;
|
||||
case 'erstellt':
|
||||
return <DateienCreated {...commonProps} />;
|
||||
case 'geteilt':
|
||||
return <DateienShared {...commonProps} />;
|
||||
default:
|
||||
return <DateienAll {...commonProps} />;
|
||||
}
|
||||
|
||||
// Apply sort direction
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
// Helper to render sort icon
|
||||
const renderSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <FaSort className={styles.sortIcon} />;
|
||||
return sortDirection === 'asc' ?
|
||||
<FaSortUp className={styles.sortIcon} /> :
|
||||
<FaSortDown className={styles.sortIcon} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dateienContainer}>
|
||||
<div className={styles.header}>
|
||||
{/* Combined Header with Tabs and Add Button */}
|
||||
<motion.div
|
||||
className={styles.combinedHeader}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className={styles.tabButtonDiv}>
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.key} className={styles.tabButtonWrapper}>
|
||||
<motion.button
|
||||
className={`${styles.tabButton} ${
|
||||
activeTab === tab.key ? styles.tabButtonActive : styles.tabButtonInactive
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{tab.label}
|
||||
</motion.button>
|
||||
<AnimatePresence>
|
||||
{activeTab === tab.key && (
|
||||
<motion.div
|
||||
className={styles.tabUnderline}
|
||||
initial={{ opacity: 0, width: "0%" }}
|
||||
animate={{ opacity: 1, width: "100%" }}
|
||||
exit={{ opacity: 0, width: "0%" }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.datei_hinzufügen_button}
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
|
|
@ -104,9 +114,10 @@ function Dateien() {
|
|||
<IoAddCircleOutline className={styles.add_icon}/>
|
||||
Datei hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className={styles.horizontalLineLight}></div>
|
||||
|
||||
<div className={styles.contentArea}>
|
||||
<DateienUpload
|
||||
isOpen={isUploadOpen}
|
||||
onClose={handleUploadClose}
|
||||
|
|
@ -118,47 +129,23 @@ function Dateien() {
|
|||
{uploadError || downloadError || deleteError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && <p>Loading files...</p>}
|
||||
{error && <p>Error: {error}</p>}
|
||||
|
||||
{!loading && !error && files.length === 0 ? (
|
||||
<p>No files found.</p>
|
||||
) : (
|
||||
<div className={styles.filesTable}>
|
||||
{/* Table Headers */}
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('file_name')}>
|
||||
<span>Name</span>
|
||||
{renderSortIcon('file_name')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('action')}>
|
||||
<span>Typ</span>
|
||||
{renderSortIcon('action')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('size')}>
|
||||
<span>Größe</span>
|
||||
{renderSortIcon('size')}
|
||||
</div>
|
||||
<div className={styles.headerCell} onClick={() => handleSort('created_at')}>
|
||||
<span>Datum</span>
|
||||
{renderSortIcon('created_at')}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Files List */}
|
||||
<ul className={styles.filesList}>
|
||||
{sortedFiles.map((file: UserFile) => (
|
||||
<DateienItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
onDelete={handleFileDeleted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{!loading && !error && (
|
||||
<motion.div
|
||||
key={activeTab} // Force re-render when tab changes
|
||||
initial={{ opacity: 0}}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{renderActiveTabContent()}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue