385 lines
14 KiB
JavaScript
385 lines
14 KiB
JavaScript
|
|
import React, { useState, useEffect } from 'react';
|
||
|
|
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, FileText, Trash2 } from 'lucide-react';
|
||
|
|
|
||
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||
|
|
|
||
|
|
export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||
|
|
const [phase, setPhase] = useState('idle'); // idle, uploading, success, error
|
||
|
|
const [selectedFile, setSelectedFile] = useState(null);
|
||
|
|
const [title, setTitle] = useState('');
|
||
|
|
const [description, setDescription] = useState('');
|
||
|
|
const [category, setCategory] = useState('General');
|
||
|
|
const [result, setResult] = useState(null);
|
||
|
|
const [existingArticles, setExistingArticles] = useState([]);
|
||
|
|
const [error, setError] = useState('');
|
||
|
|
|
||
|
|
// Fetch existing articles on mount
|
||
|
|
useEffect(() => {
|
||
|
|
fetchExistingArticles();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const fetchExistingArticles = async () => {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
|
||
|
|
if (!response.ok) throw new Error('Failed to fetch articles');
|
||
|
|
const data = await response.json();
|
||
|
|
setExistingArticles(data);
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Error fetching articles:', err);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleFileSelect = (e) => {
|
||
|
|
const file = e.target.files[0];
|
||
|
|
if (file) {
|
||
|
|
// Validate file type
|
||
|
|
const allowedExtensions = ['.pdf', '.md', '.txt', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.html', '.json', '.yaml', '.yml'];
|
||
|
|
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||
|
|
|
||
|
|
if (!allowedExtensions.includes(ext)) {
|
||
|
|
setError('File type not allowed. Please upload: PDF, Markdown, Text, Office docs, or HTML files.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setSelectedFile(file);
|
||
|
|
setError('');
|
||
|
|
|
||
|
|
// Auto-populate title from filename if empty
|
||
|
|
if (!title) {
|
||
|
|
const filename = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
|
||
|
|
setTitle(filename);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleUpload = async () => {
|
||
|
|
if (!selectedFile || !title.trim()) {
|
||
|
|
setError('Please provide both a title and file');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setPhase('uploading');
|
||
|
|
|
||
|
|
const formData = new FormData();
|
||
|
|
formData.append('file', selectedFile);
|
||
|
|
formData.append('title', title.trim());
|
||
|
|
formData.append('description', description.trim());
|
||
|
|
formData.append('category', category);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${API_BASE}/knowledge-base/upload`, {
|
||
|
|
method: 'POST',
|
||
|
|
body: formData,
|
||
|
|
credentials: 'include'
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const errorData = await response.json();
|
||
|
|
throw new Error(errorData.error || 'Upload failed');
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
setResult(data);
|
||
|
|
setPhase('success');
|
||
|
|
|
||
|
|
// Refresh the list of existing articles
|
||
|
|
await fetchExistingArticles();
|
||
|
|
|
||
|
|
// Notify parent to refresh
|
||
|
|
if (onUpdate) onUpdate();
|
||
|
|
} catch (err) {
|
||
|
|
setError(err.message);
|
||
|
|
setPhase('error');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDownload = async (id, filename) => {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, {
|
||
|
|
credentials: 'include'
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) throw new Error('Download failed');
|
||
|
|
|
||
|
|
const blob = await response.blob();
|
||
|
|
const url = window.URL.createObjectURL(blob);
|
||
|
|
const a = document.createElement('a');
|
||
|
|
a.href = url;
|
||
|
|
a.download = filename;
|
||
|
|
document.body.appendChild(a);
|
||
|
|
a.click();
|
||
|
|
window.URL.revokeObjectURL(url);
|
||
|
|
document.body.removeChild(a);
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Error downloading file:', err);
|
||
|
|
setError('Failed to download file');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDelete = async (id, articleTitle) => {
|
||
|
|
if (!window.confirm(`Are you sure you want to delete "${articleTitle}"?`)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${API_BASE}/knowledge-base/${id}`, {
|
||
|
|
method: 'DELETE',
|
||
|
|
credentials: 'include'
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) throw new Error('Delete failed');
|
||
|
|
|
||
|
|
// Refresh the list
|
||
|
|
await fetchExistingArticles();
|
||
|
|
|
||
|
|
// Notify parent to refresh
|
||
|
|
if (onUpdate) onUpdate();
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Error deleting article:', err);
|
||
|
|
setError('Failed to delete article');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const resetForm = () => {
|
||
|
|
setPhase('idle');
|
||
|
|
setSelectedFile(null);
|
||
|
|
setTitle('');
|
||
|
|
setDescription('');
|
||
|
|
setCategory('General');
|
||
|
|
setResult(null);
|
||
|
|
setError('');
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatFileSize = (bytes) => {
|
||
|
|
if (!bytes) return 'Unknown size';
|
||
|
|
if (bytes < 1024) return bytes + ' B';
|
||
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatDate = (dateString) => {
|
||
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'short',
|
||
|
|
day: 'numeric'
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const getCategoryColor = (cat) => {
|
||
|
|
const colors = {
|
||
|
|
'General': '#94A3B8',
|
||
|
|
'Policy': '#0EA5E9',
|
||
|
|
'Procedure': '#10B981',
|
||
|
|
'Guide': '#F59E0B',
|
||
|
|
'Reference': '#8B5CF6'
|
||
|
|
};
|
||
|
|
return colors[cat] || '#94A3B8';
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="modal-overlay" onClick={onClose}>
|
||
|
|
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px' }}>
|
||
|
|
{/* Header */}
|
||
|
|
<div className="modal-header">
|
||
|
|
<h2 className="modal-title">Knowledge Base</h2>
|
||
|
|
<button onClick={onClose} className="modal-close">
|
||
|
|
<X className="w-5 h-5" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Body */}
|
||
|
|
<div className="modal-body">
|
||
|
|
{/* Idle Phase - Upload Form */}
|
||
|
|
{phase === 'idle' && (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||
|
|
Title *
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={title}
|
||
|
|
onChange={(e) => setTitle(e.target.value)}
|
||
|
|
placeholder="e.g., Inventory Management Policy"
|
||
|
|
className="intel-input w-full"
|
||
|
|
maxLength={255}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||
|
|
Description
|
||
|
|
</label>
|
||
|
|
<textarea
|
||
|
|
value={description}
|
||
|
|
onChange={(e) => setDescription(e.target.value)}
|
||
|
|
placeholder="Brief description of this document..."
|
||
|
|
className="intel-input w-full"
|
||
|
|
rows={3}
|
||
|
|
maxLength={500}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||
|
|
Category
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
value={category}
|
||
|
|
onChange={(e) => setCategory(e.target.value)}
|
||
|
|
className="intel-input w-full"
|
||
|
|
>
|
||
|
|
<option value="General">General</option>
|
||
|
|
<option value="Policy">Policy</option>
|
||
|
|
<option value="Procedure">Procedure</option>
|
||
|
|
<option value="Guide">Guide</option>
|
||
|
|
<option value="Reference">Reference</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||
|
|
Document File *
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="file"
|
||
|
|
accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.html,.json,.yaml,.yml"
|
||
|
|
onChange={handleFileSelect}
|
||
|
|
className="intel-input w-full"
|
||
|
|
/>
|
||
|
|
{selectedFile && (
|
||
|
|
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
||
|
|
Selected: {selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<button
|
||
|
|
onClick={handleUpload}
|
||
|
|
disabled={!selectedFile || !title.trim()}
|
||
|
|
className={`intel-button w-full ${selectedFile && title.trim() ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
||
|
|
>
|
||
|
|
<UploadIcon className="w-4 h-4 mr-2" />
|
||
|
|
Upload Document
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||
|
|
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||
|
|
<p style={{ color: '#FCA5A5' }}>{error}</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Uploading Phase */}
|
||
|
|
{phase === 'uploading' && (
|
||
|
|
<div className="text-center py-8">
|
||
|
|
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||
|
|
<p style={{ color: '#94A3B8' }}>Uploading document...</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Success Phase */}
|
||
|
|
{phase === 'success' && result && (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
|
||
|
|
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
|
||
|
|
<div>
|
||
|
|
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
|
||
|
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
|
||
|
|
{result.title} has been added to the knowledge base.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<button onClick={resetForm} className="intel-button w-full">
|
||
|
|
Upload Another Document
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Error Phase */}
|
||
|
|
{phase === 'error' && (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||
|
|
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||
|
|
<div>
|
||
|
|
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
|
||
|
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<button onClick={resetForm} className="intel-button w-full">
|
||
|
|
Try Again
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Existing Articles Section */}
|
||
|
|
{(phase === 'idle' || phase === 'success') && existingArticles.length > 0 && (
|
||
|
|
<div className="mt-8">
|
||
|
|
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
||
|
|
Existing Documents ({existingArticles.length})
|
||
|
|
</h3>
|
||
|
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||
|
|
{existingArticles.map((article) => (
|
||
|
|
<div
|
||
|
|
key={article.id}
|
||
|
|
className="intel-card p-4"
|
||
|
|
>
|
||
|
|
<div className="flex items-start justify-between gap-3">
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div className="flex items-center gap-2 mb-1">
|
||
|
|
<FileText className="w-4 h-4 flex-shrink-0" style={{ color: getCategoryColor(article.category) }} />
|
||
|
|
<p className="font-medium truncate" style={{ color: '#E2E8F0' }}>
|
||
|
|
{article.title}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
{article.description && (
|
||
|
|
<p className="text-sm mb-2 line-clamp-2" style={{ color: '#94A3B8' }}>
|
||
|
|
{article.description}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||
|
|
<span
|
||
|
|
className="px-2 py-0.5 rounded"
|
||
|
|
style={{
|
||
|
|
background: `${getCategoryColor(article.category)}33`,
|
||
|
|
color: getCategoryColor(article.category)
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{article.category}
|
||
|
|
</span>
|
||
|
|
<span>{formatDate(article.created_at)}</span>
|
||
|
|
<span>{formatFileSize(article.file_size)}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-2 flex-shrink-0">
|
||
|
|
<button
|
||
|
|
onClick={() => handleDownload(article.id, article.file_name)}
|
||
|
|
className="intel-button intel-button-small intel-button-success"
|
||
|
|
title="Download"
|
||
|
|
>
|
||
|
|
<Download className="w-3 h-3" />
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => handleDelete(article.id, article.title)}
|
||
|
|
className="intel-button intel-button-small"
|
||
|
|
style={{ borderColor: '#EF4444', color: '#EF4444' }}
|
||
|
|
title="Delete"
|
||
|
|
>
|
||
|
|
<Trash2 className="w-3 h-3" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|