Added knowledge base enhancements for documentation viewing and preloaded Ivanti config for next feature

This commit is contained in:
2026-02-13 09:43:09 -07:00
parent 6fda7de7a3
commit 79a1a23002
11 changed files with 2344 additions and 33 deletions

View File

@@ -647,3 +647,179 @@ h3.text-intel-accent {
inset 0 2px 4px rgba(0, 0, 0, 0.25),
0 2px 8px rgba(14, 165, 233, 0.1);
}
/* Knowledge Base Content Area */
.kb-content-area {
min-height: 400px;
max-height: 700px;
overflow-y: auto;
padding-right: 0.5rem;
}
/* Markdown Content Styling */
.markdown-content {
color: #E2E8F0;
line-height: 1.7;
font-size: 0.95rem;
}
.markdown-content h1 {
font-size: 2rem;
font-weight: 700;
color: #0EA5E9;
margin-top: 1.5rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(14, 165, 233, 0.3);
font-family: monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.markdown-content h2 {
font-size: 1.5rem;
font-weight: 600;
color: #10B981;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
font-family: monospace;
}
.markdown-content h3 {
font-size: 1.25rem;
font-weight: 600;
color: #F59E0B;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
font-family: monospace;
}
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
font-size: 1.1rem;
font-weight: 600;
color: #94A3B8;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.markdown-content p {
margin-bottom: 1rem;
color: #CBD5E1;
}
.markdown-content a {
color: #0EA5E9;
text-decoration: none;
border-bottom: 1px solid rgba(14, 165, 233, 0.3);
transition: all 0.2s;
}
.markdown-content a:hover {
color: #38BDF8;
border-bottom-color: #38BDF8;
}
.markdown-content ul,
.markdown-content ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
color: #CBD5E1;
}
.markdown-content li {
margin-bottom: 0.5rem;
}
.markdown-content code {
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(14, 165, 233, 0.2);
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-family: 'Courier New', monospace;
font-size: 0.9em;
color: #10B981;
}
.markdown-content pre {
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(14, 165, 233, 0.3);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
overflow-x: auto;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
}
.markdown-content pre code {
background: none;
border: none;
padding: 0;
color: #E2E8F0;
font-size: 0.875rem;
}
.markdown-content blockquote {
border-left: 4px solid #0EA5E9;
padding-left: 1rem;
margin: 1rem 0;
color: #94A3B8;
font-style: italic;
background: rgba(14, 165, 233, 0.05);
padding: 0.75rem 1rem;
border-radius: 0.25rem;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.markdown-content th,
.markdown-content td {
border: 1px solid rgba(14, 165, 233, 0.2);
padding: 0.5rem 0.75rem;
text-align: left;
}
.markdown-content th {
background: rgba(14, 165, 233, 0.1);
color: #0EA5E9;
font-weight: 600;
font-family: monospace;
}
.markdown-content td {
color: #CBD5E1;
}
.markdown-content tr:hover {
background: rgba(14, 165, 233, 0.05);
}
.markdown-content hr {
border: none;
border-top: 1px solid rgba(14, 165, 233, 0.2);
margin: 2rem 0;
}
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
border: 1px solid rgba(14, 165, 233, 0.3);
margin: 1rem 0;
}
.markdown-content strong {
color: #F8FAFC;
font-weight: 600;
}
.markdown-content em {
color: #CBD5E1;
font-style: italic;
}

View File

@@ -7,6 +7,8 @@ import UserManagement from './components/UserManagement';
import AuditLog from './components/AuditLog';
import NvdSyncModal from './components/NvdSyncModal';
import WeeklyReportModal from './components/WeeklyReportModal';
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
import './App.css';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -175,6 +177,9 @@ export default function App() {
const [showAuditLog, setShowAuditLog] = useState(false);
const [showNvdSync, setShowNvdSync] = useState(false);
const [showWeeklyReport, setShowWeeklyReport] = useState(false);
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
const [newCVE, setNewCVE] = useState({
cve_id: '',
vendor: '',
@@ -278,6 +283,19 @@ export default function App() {
}
};
const fetchKnowledgeBaseArticles = async () => {
try {
const response = await fetch(`${API_BASE}/knowledge-base`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch knowledge base articles');
const data = await response.json();
setKnowledgeBaseArticles(data);
} catch (err) {
console.error('Error fetching knowledge base articles:', err);
}
};
const fetchJiraTickets = async () => {
try {
const response = await fetch(`${API_BASE}/jira-tickets`, {
@@ -346,6 +364,45 @@ export default function App() {
alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
};
const handleViewKBArticle = async (articleId) => {
try {
const response = await fetch(`${API_BASE}/knowledge-base/${articleId}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch article');
const article = await response.json();
setSelectedKBArticle(article);
} catch (err) {
console.error('Error fetching knowledge base article:', err);
setError('Failed to load article');
}
};
const handleDownloadKBArticle = 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 knowledge base article:', err);
setError('Failed to download document');
}
};
const handleAddCVE = async (e) => {
e.preventDefault();
try {
@@ -694,6 +751,7 @@ export default function App() {
fetchCVEs();
fetchVendors();
fetchJiraTickets();
fetchKnowledgeBaseArticles();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]);
@@ -826,6 +884,14 @@ export default function App() {
<WeeklyReportModal onClose={() => setShowWeeklyReport(false)} />
)}
{/* Knowledge Base Modal */}
{showKnowledgeBase && (
<KnowledgeBaseModal
onClose={() => setShowKnowledgeBase(false)}
onUpdate={fetchKnowledgeBaseArticles}
/>
)}
{/* Add CVE Modal */}
{showAddCVE && (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
@@ -1276,47 +1342,85 @@ export default function App() {
{/* LEFT PANEL - Wiki/Knowledge Base */}
<div className="col-span-12 lg:col-span-3 space-y-4">
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#10B981', marginBottom: '1rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(16, 185, 129, 0.4)' }}>
Knowledge Base
</h2>
<div className="flex items-center justify-between mb-4">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#10B981', marginBottom: '0', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(16, 185, 129, 0.4)' }}>
Knowledge Base
</h2>
{(user?.role === 'admin' || user?.role === 'editor') && (
<button
onClick={() => setShowKnowledgeBase(true)}
className="intel-button intel-button-small"
style={{ fontSize: '0.75rem', padding: '0.375rem 0.75rem' }}
title="Manage Knowledge Base"
>
<Plus className="w-3 h-3" />
</button>
)}
</div>
{/* Wiki/Blog Style Entries */}
{/* Knowledge Base Entries */}
<div className="space-y-3">
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
<h3 className="text-white font-semibold text-sm mb-1 font-mono">CVE Response Procedures</h3>
<p className="text-gray-400 text-xs mb-2">Standard operating procedures for vulnerability response and escalation...</p>
<span className="text-xs text-intel-success font-mono">Last updated: 2024-02-08</span>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Vendor Contact Matrix</h3>
<p className="text-gray-400 text-xs mb-2">Emergency contacts and escalation paths for security vendors...</p>
<span className="text-xs text-intel-success font-mono">Last updated: 2024-02-05</span>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Severity Classification Guide</h3>
<p className="text-gray-400 text-xs mb-2">Guidelines for assessing and classifying vulnerability severity levels...</p>
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-28</span>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Patching Policy</h3>
<p className="text-gray-400 text-xs mb-2">Enterprise patch management timelines and approval workflow...</p>
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-15</span>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Documentation Standards</h3>
<p className="text-gray-400 text-xs mb-2">Required documentation for vulnerability tracking and audit compliance...</p>
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-10</span>
</div>
{knowledgeBaseArticles.length === 0 ? (
<div className="text-center py-8" style={{ color: '#64748B' }}>
<FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">No documents yet</p>
{(user?.role === 'admin' || user?.role === 'editor') && (
<button
onClick={() => setShowKnowledgeBase(true)}
className="intel-button intel-button-small mt-3"
>
Add First Document
</button>
)}
</div>
) : (
knowledgeBaseArticles.slice(0, 5).map((article) => (
<div
key={article.id}
onClick={() => handleViewKBArticle(article.id)}
style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }}
className="hover:border-intel-success"
>
<h3 className="text-white font-semibold text-sm mb-1 font-mono">{article.title}</h3>
{article.description && (
<p className="text-gray-400 text-xs mb-2 line-clamp-2">{article.description}</p>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-intel-success font-mono">
{new Date(article.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
{article.category && article.category !== 'General' && (
<span className="text-xs px-2 py-0.5 rounded" style={{ background: 'rgba(16, 185, 129, 0.2)', color: '#10B981' }}>
{article.category}
</span>
)}
</div>
</div>
))
)}
{knowledgeBaseArticles.length > 5 && (
<button
onClick={() => setShowKnowledgeBase(true)}
className="text-xs text-center w-full py-2"
style={{ color: '#10B981' }}
>
View all {knowledgeBaseArticles.length} documents
</button>
)}
</div>
</div>
</div>
{/* CENTER PANEL - Main Content */}
<div className="col-span-12 lg:col-span-6 space-y-4">
{/* Knowledge Base Viewer */}
{selectedKBArticle ? (
<KnowledgeBaseViewer
article={selectedKBArticle}
onClose={() => setSelectedKBArticle(null)}
/>
) : (
<>
{/* Quick Check */}
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
<div className="scan-line"></div>
@@ -1753,6 +1857,8 @@ export default function App() {
<p className="text-gray-300">Try adjusting your search criteria or filters</p>
</div>
)}
</>
)}
</div>
{/* End Center Panel */}

View File

@@ -0,0 +1,384 @@
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>
);
}

View File

@@ -0,0 +1,236 @@
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { X, Download, Loader, AlertCircle, FileText, File } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
export default function KnowledgeBaseViewer({ article, onClose }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetchArticleContent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [article.id]);
const fetchArticleContent = async () => {
setLoading(true);
setError('');
try {
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/content`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch article content');
const text = await response.text();
setContent(text);
} catch (err) {
console.error('Error fetching article content:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
const handleDownload = async () => {
try {
const response = await fetch(`${API_BASE}/knowledge-base/${article.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 = article.file_name;
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 isMarkdown = article.file_name?.endsWith('.md');
const isText = article.file_name?.endsWith('.txt');
const isPDF = article.file_name?.endsWith('.pdf');
const isImage = /\.(jpg|jpeg|png|gif|bmp)$/i.test(article.file_name || '');
const getCategoryColor = (cat) => {
const colors = {
'General': '#94A3B8',
'Policy': '#0EA5E9',
'Procedure': '#10B981',
'Guide': '#F59E0B',
'Reference': '#8B5CF6'
};
return colors[cat] || '#94A3B8';
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
return (
<div
style={{
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
border: '2px solid rgba(14, 165, 233, 0.4)',
borderRadius: '0.5rem',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15)',
padding: '1.5rem',
position: 'relative',
overflow: 'hidden'
}}
>
{/* Header */}
<div className="flex items-start justify-between mb-4 pb-4" style={{ borderBottom: '1px solid rgba(14, 165, 233, 0.2)' }}>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<FileText className="w-5 h-5" style={{ color: getCategoryColor(article.category) }} />
<h2 className="text-xl font-semibold" style={{ color: '#E2E8F0', fontFamily: 'monospace' }}>
{article.title}
</h2>
</div>
{article.description && (
<p className="text-sm mb-2" style={{ color: '#94A3B8' }}>
{article.description}
</p>
)}
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
<span
className="px-2 py-1 rounded"
style={{
background: `${getCategoryColor(article.category)}33`,
color: getCategoryColor(article.category),
fontWeight: '600'
}}
>
{article.category}
</span>
<span>Created: {formatDate(article.created_at)}</span>
{article.created_by_username && (
<span>By: {article.created_by_username}</span>
)}
</div>
</div>
<div className="flex gap-2 ml-4">
<button
onClick={handleDownload}
className="intel-button intel-button-small"
title="Download"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="intel-button intel-button-small"
title="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Content */}
<div className="kb-content-area">
{loading && (
<div className="text-center py-12">
<Loader className="w-8 h-8 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
<p style={{ color: '#94A3B8' }}>Loading document...</p>
</div>
)}
{error && (
<div className="flex items-start gap-3 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' }}>Failed to Load Document</p>
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
</div>
</div>
)}
{!loading && !error && (
<>
{/* Markdown Rendering */}
{isMarkdown && (
<div className="markdown-content">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
)}
{/* Plain Text */}
{isText && !isMarkdown && (
<pre
className="text-sm p-4 rounded overflow-auto"
style={{
background: 'rgba(15, 23, 42, 0.8)',
border: '1px solid rgba(14, 165, 233, 0.2)',
color: '#E2E8F0',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
maxHeight: '600px'
}}
>
{content}
</pre>
)}
{/* PDF */}
{isPDF && (
<div className="text-center py-12">
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#EF4444' }} />
<p className="mb-4" style={{ color: '#94A3B8' }}>
PDF Preview not available. Click the download button to view this file.
</p>
<button onClick={handleDownload} className="intel-button intel-button-success">
<Download className="w-4 h-4 mr-2" />
Download PDF
</button>
</div>
)}
{/* Images */}
{isImage && (
<div className="text-center">
<img
src={`${API_BASE}/knowledge-base/${article.id}/content`}
alt={article.title}
className="max-w-full h-auto rounded"
style={{ border: '1px solid rgba(14, 165, 233, 0.3)' }}
/>
</div>
)}
{/* Other file types */}
{!isMarkdown && !isText && !isPDF && !isImage && (
<div className="text-center py-12">
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#94A3B8' }} />
<p className="mb-4" style={{ color: '#94A3B8' }}>
Preview not available for this file type.
</p>
<button onClick={handleDownload} className="intel-button intel-button-success">
<Download className="w-4 h-4 mr-2" />
Download File
</button>
</div>
)}
</>
)}
</div>
</div>
);
}