3 Commits

Author SHA1 Message Date
0d48c109b3 refactor(home): remove Knowledge Base panel from home page
The dedicated Knowledge Base page now provides the full library
experience. Remove the KB sidebar panel, viewer inline embed,
upload modal, and all supporting state/functions from App.js.

Home page layout adjusts from 3-column to 2-column (9+3 grid):
main CVE content expands to col-span-9, right panel unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:32:36 -06:00
18ad31228e feat(kb): build Knowledge Base page
Replaces the 'coming soon' placeholder with a full library UI.
No backend changes needed — all existing endpoints and components
(KnowledgeBaseViewer, KnowledgeBaseModal) are reused.

Features:
  - Article card grid (responsive auto-fill, min 240px per card)
  - Category filter tabs (Procedure, Guide, Policy, Reference, General)
    with live article counts; tabs only shown for populated categories
  - Search bar — filters by title and description, client-side
  - Inline viewer — clicking a card opens KnowledgeBaseViewer below
    the grid; clicking again or pressing the close button collapses it
  - Upload modal (editor/admin only) refreshes the grid on success
  - Delete button on each card (editor/admin only) with confirmation
  - Graceful empty states for no articles and no search results
  - Loading and error states with retry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:55:51 -06:00
3dcb91a1fc chore(migrations): add migration script for ivanti_counts_history table
Standalone migration script for consistency with other files in
backend/migrations/. The table is also created automatically at server
startup via CREATE TABLE IF NOT EXISTS in initTables() so no manual
step is required on the dev server — just restart the backend after
pulling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:17:33 -06:00
3 changed files with 526 additions and 172 deletions

View File

@@ -0,0 +1,41 @@
// Migration: Add ivanti_counts_history table
//
// Stores a snapshot of open/closed Ivanti finding counts on every sync.
// Unlike ivanti_counts_cache (single-row, always overwritten), this table
// accumulates all snapshots so time-series charts can be built from it.
//
// The GET /api/ivanti/findings/counts/history endpoint aggregates these rows
// to the last snapshot per calendar day using a ROW_NUMBER window function.
//
// NOTE: This table is also created automatically at server startup via
// CREATE TABLE IF NOT EXISTS in initTables() (ivantiFindings.js).
// This script is provided for manual setup on fresh installs and for
// documentation consistency with other migration files.
//
// Usage: node backend/migrations/add_ivanti_counts_history_table.js
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting ivanti_counts_history migration...');
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
open_count INTEGER NOT NULL,
closed_count INTEGER NOT NULL,
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) console.error('Error creating ivanti_counts_history table:', err);
else console.log('✓ ivanti_counts_history table created (or already exists)');
});
});
db.close(() => {
console.log('Migration complete.');
});

View File

@@ -6,8 +6,6 @@ import UserMenu from './components/UserMenu';
import UserManagement from './components/UserManagement'; import UserManagement from './components/UserManagement';
import AuditLog from './components/AuditLog'; import AuditLog from './components/AuditLog';
import NvdSyncModal from './components/NvdSyncModal'; import NvdSyncModal from './components/NvdSyncModal';
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
import NavDrawer from './components/NavDrawer'; import NavDrawer from './components/NavDrawer';
import CalendarWidget from './components/CalendarWidget'; import CalendarWidget from './components/CalendarWidget';
import VulnerabilityTriagePage from './components/pages/ReportingPage'; import VulnerabilityTriagePage from './components/pages/ReportingPage';
@@ -185,9 +183,6 @@ export default function App() {
const [showUserManagement, setShowUserManagement] = useState(false); const [showUserManagement, setShowUserManagement] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false);
const [showNvdSync, setShowNvdSync] = useState(false); const [showNvdSync, setShowNvdSync] = useState(false);
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
const [newCVE, setNewCVE] = useState({ const [newCVE, setNewCVE] = useState({
cve_id: '', cve_id: '',
vendor: '', vendor: '',
@@ -311,19 +306,6 @@ 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 () => { const fetchJiraTickets = async () => {
try { try {
const response = await fetch(`${API_BASE}/jira-tickets`, { const response = await fetch(`${API_BASE}/jira-tickets`, {
@@ -442,45 +424,6 @@ export default function App() {
alert(`Exporting ${selectedDocuments.length} documents for report attachment`); 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) => { const handleAddCVE = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
@@ -916,7 +859,6 @@ export default function App() {
fetchJiraTickets(); fetchJiraTickets();
fetchArcherTickets(); fetchArcherTickets();
fetchIvantiWorkflows(); fetchIvantiWorkflows();
fetchKnowledgeBaseArticles();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]); }, [isAuthenticated]);
@@ -1063,14 +1005,6 @@ export default function App() {
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} /> <NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
)} )}
{/* Knowledge Base Modal */}
{showKnowledgeBase && (
<KnowledgeBaseModal
onClose={() => setShowKnowledgeBase(false)}
onUpdate={fetchKnowledgeBaseArticles}
/>
)}
{/* Add CVE Modal */} {/* Add CVE Modal */}
{showAddCVE && ( {showAddCVE && (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
@@ -1661,90 +1595,11 @@ export default function App() {
</div> </div>
)} )}
{/* Three Column Layout - Home page only */} {/* Two Column Layout - Home page only */}
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6"> {currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
{/* 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">
<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>
{/* Knowledge Base Entries */}
<div className="space-y-3">
{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 */} {/* CENTER PANEL - Main Content */}
<div className="col-span-12 lg:col-span-6 space-y-4"> <div className="col-span-12 lg:col-span-9 space-y-4">
{/* Knowledge Base Viewer */} <>
{selectedKBArticle ? (
<KnowledgeBaseViewer
article={selectedKBArticle}
onClose={() => setSelectedKBArticle(null)}
/>
) : (
<>
{/* Quick Check */} {/* Quick Check */}
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg"> <div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
<div className="scan-line"></div> <div className="scan-line"></div>
@@ -2216,7 +2071,6 @@ export default function App() {
</div> </div>
)} )}
</> </>
)}
</div> </div>
{/* End Center Panel */} {/* End Center Panel */}

View File

@@ -1,25 +1,484 @@
import React from 'react'; // KnowledgeBasePage.js
import { BookOpen } from 'lucide-react'; // Full-page knowledge base library — browse, search, filter, and read
// articles inline. Upload and delete require editor/admin role.
// Reuses existing KnowledgeBaseViewer and KnowledgeBaseModal components.
export default function KnowledgeBasePage() { import React, { useState, useEffect, useCallback, useMemo } from 'react';
return ( import {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}> BookOpen, Search, Upload, RefreshCw, Loader,
<div style={{ textAlign: 'center' }}> AlertCircle, FileText, File, Trash2, X,
<div style={{ } from 'lucide-react';
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem', import { useAuth } from '../../contexts/AuthContext';
background: 'rgba(16, 185, 129, 0.1)', import KnowledgeBaseModal from '../KnowledgeBaseModal';
border: '1px solid rgba(16, 185, 129, 0.3)', import KnowledgeBaseViewer from '../KnowledgeBaseViewer';
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}> const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
<BookOpen style={{ width: '36px', height: '36px', color: '#10B981' }} /> const GREEN = '#10B981';
</div>
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#10B981', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}> // ---------------------------------------------------------------------------
Knowledge Base // Static config
</h2> // ---------------------------------------------------------------------------
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}> const CATEGORY_COLORS = {
Under construction coming soon General: '#94A3B8',
</p> Policy: '#0EA5E9',
</div> Procedure: GREEN,
</div> Guide: '#F59E0B',
); Reference: '#8B5CF6',
};
const FILE_EXT_COLORS = {
pdf: '#EF4444',
md: '#10B981',
txt: '#94A3B8',
doc: '#0EA5E9',
docx: '#0EA5E9',
xls: '#10B981',
xlsx: '#10B981',
ppt: '#F97316',
pptx: '#F97316',
html: '#8B5CF6',
};
const CATEGORY_ORDER = ['Procedure', 'Guide', 'Policy', 'Reference', 'General'];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function extOf(filename) {
return (filename || '').split('.').pop().toLowerCase();
}
function extColor(filename) {
return FILE_EXT_COLORS[extOf(filename)] || '#64748B';
}
function fmtSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function fmtDate(str) {
if (!str) return '';
return new Date(str).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
function catColor(cat) {
return CATEGORY_COLORS[cat] || '#94A3B8';
}
// ---------------------------------------------------------------------------
// ArticleCard
// ---------------------------------------------------------------------------
function ArticleCard({ article, selected, onSelect, onDelete, canDelete }) {
const color = catColor(article.category);
const fileColor = extColor(article.file_name);
const ext = extOf(article.file_name).toUpperCase();
return (
<div
onClick={() => onSelect(article)}
style={{
background: selected
? `linear-gradient(135deg,rgba(16,185,129,0.1) 0%,rgba(15,23,42,0.98) 100%)`
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
border: `1.5px solid ${selected ? GREEN : 'rgba(16,185,129,0.12)'}`,
borderRadius: '0.5rem',
padding: '1rem',
cursor: 'pointer',
transition: 'all 0.15s',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.35)'; }}
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.12)'; }}
>
{/* File type badge + delete button */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{
fontFamily: 'monospace', fontSize: '0.58rem', fontWeight: '700',
color: fileColor, padding: '0.15rem 0.4rem',
background: `${fileColor}15`, borderRadius: '0.2rem',
border: `1px solid ${fileColor}30`,
}}>
{ext}
</span>
{canDelete && (
<button
onClick={e => { e.stopPropagation(); onDelete(article); }}
title="Delete article"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#334155', padding: '0.15rem',
borderRadius: '0.2rem', display: 'flex', alignItems: 'center',
}}
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; }}
>
<Trash2 style={{ width: '12px', height: '12px' }} />
</button>
)}
</div>
{/* Title */}
<div style={{
fontFamily: 'monospace', fontSize: '0.82rem', fontWeight: '700',
color: selected ? GREEN : '#E2E8F0',
lineHeight: 1.3,
}}>
{article.title}
</div>
{/* Description */}
{article.description && (
<div style={{
fontSize: '0.7rem', color: '#475569',
lineHeight: 1.45, display: '-webkit-box',
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{article.description}
</div>
)}
{/* Footer — category + date */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
<span style={{
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
color, padding: '0.15rem 0.4rem',
background: `${color}12`, borderRadius: '0.2rem',
border: `1px solid ${color}25`,
textTransform: 'uppercase', letterSpacing: '0.04em',
}}>
{article.category}
</span>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{article.file_size && (
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
{fmtSize(article.file_size)}
</span>
)}
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
{fmtDate(article.created_at)}
</span>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function EmptyState({ hasFilter, onClear }) {
return (
<div style={{
gridColumn: '1 / -1',
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', padding: '4rem 2rem',
border: '1px dashed rgba(16,185,129,0.15)', borderRadius: '0.5rem',
color: '#334155',
}}>
<BookOpen style={{ width: '36px', height: '36px', marginBottom: '1rem', opacity: 0.4 }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.8rem', marginBottom: '0.375rem' }}>
{hasFilter ? 'No articles match your search' : 'No articles yet'}
</div>
{hasFilter ? (
<button onClick={onClear} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: GREEN, fontFamily: 'monospace', fontSize: '0.72rem',
marginTop: '0.375rem',
}}>
Clear filters
</button>
) : (
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
Upload a document to get started
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function KnowledgeBasePage() {
const { canWrite } = useAuth();
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState('All');
const [selected, setSelected] = useState(null);
const [showUpload, setShowUpload] = useState(false);
// -------------------------------------------------------------------------
// Fetch
// -------------------------------------------------------------------------
const fetchArticles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load articles');
const data = await res.json();
setArticles(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchArticles(); }, [fetchArticles]);
// -------------------------------------------------------------------------
// Delete
// -------------------------------------------------------------------------
const handleDelete = useCallback(async (article) => {
if (!window.confirm(`Delete "${article.title}"? This cannot be undone.`)) return;
try {
const res = await fetch(`${API_BASE}/knowledge-base/${article.id}`, {
method: 'DELETE', credentials: 'include',
});
if (!res.ok) throw new Error('Delete failed');
setArticles(prev => prev.filter(a => a.id !== article.id));
if (selected?.id === article.id) setSelected(null);
} catch (err) {
alert(`Failed to delete: ${err.message}`);
}
}, [selected]);
// -------------------------------------------------------------------------
// Filtering
// -------------------------------------------------------------------------
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return articles.filter(a => {
const matchesCat = activeCategory === 'All' || a.category === activeCategory;
const matchesSearch = !q ||
a.title.toLowerCase().includes(q) ||
(a.description || '').toLowerCase().includes(q);
return matchesCat && matchesSearch;
});
}, [articles, activeCategory, search]);
// Category tab counts (always from full list, not filtered by search)
const categoryCounts = useMemo(() => {
const counts = { All: articles.length };
CATEGORY_ORDER.forEach(cat => {
counts[cat] = articles.filter(a => a.category === cat).length;
});
return counts;
}, [articles]);
const activeTabs = ['All', ...CATEGORY_ORDER.filter(c => categoryCounts[c] > 0)];
const clearFilters = () => { setSearch(''); setActiveCategory('All'); };
const hasFilter = search.trim() !== '' || activeCategory !== 'All';
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem', paddingBottom: '2rem' }}>
{/* ── Page header ─────────────────────────────────────────── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2 style={{
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
color: GREEN, textTransform: 'uppercase', letterSpacing: '0.1em',
textShadow: `0 0 16px ${GREEN}40`, marginBottom: '0.25rem',
}}>
Knowledge Base
</h2>
<div style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
{loading ? '…' : `${articles.length} article${articles.length !== 1 ? 's' : ''}`}
{articles.length > 0 && activeCategory !== 'All' && (
<span style={{ marginLeft: '0.5rem', color: '#334155' }}>
· {categoryCounts[activeCategory] || 0} in {activeCategory}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
onClick={fetchArticles}
title="Refresh"
style={{
background: 'none', border: `1px solid rgba(16,185,129,0.25)`,
borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569',
}}
onMouseEnter={e => { e.currentTarget.style.color = GREEN; e.currentTarget.style.borderColor = `${GREEN}60`; }}
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(16,185,129,0.25)'; }}
>
<RefreshCw style={{ width: '16px', height: '16px' }} />
</button>
{canWrite() && (
<button
onClick={() => setShowUpload(true)}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
background: `${GREEN}18`, border: `1px solid ${GREEN}`,
color: GREEN, padding: '0.5rem 1rem',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
cursor: 'pointer', borderRadius: '0.375rem',
}}
>
<Upload style={{ width: '14px', height: '14px' }} />
Upload Article
</button>
)}
</div>
</div>
{/* ── Search + category tabs ───────────────────────────────── */}
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
{/* Search */}
<div style={{ position: 'relative', flexShrink: 0 }}>
<Search style={{
position: 'absolute', left: '0.625rem', top: '50%', transform: 'translateY(-50%)',
width: '13px', height: '13px', color: '#334155', pointerEvents: 'none',
}} />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search articles…"
style={{
paddingLeft: '2rem', paddingRight: search ? '2rem' : '0.625rem',
paddingTop: '0.4rem', paddingBottom: '0.4rem',
background: 'rgba(15,23,42,0.8)',
border: '1px solid rgba(16,185,129,0.2)',
borderRadius: '0.375rem', color: '#E2E8F0',
outline: 'none', fontFamily: 'monospace', fontSize: '0.75rem',
width: '220px',
}}
onFocus={e => e.target.style.borderColor = `${GREEN}60`}
onBlur={e => e.target.style.borderColor = 'rgba(16,185,129,0.2)'}
/>
{search && (
<button
onClick={() => setSearch('')}
style={{
position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: 0,
}}
>
<X style={{ width: '12px', height: '12px' }} />
</button>
)}
</div>
{/* Category tabs */}
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap' }}>
{activeTabs.map(cat => {
const isActive = activeCategory === cat;
const color = cat === 'All' ? GREEN : catColor(cat);
return (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
style={{
padding: '0.35rem 0.75rem',
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
cursor: 'pointer', borderRadius: '0.25rem',
border: isActive ? `1px solid ${color}` : '1px solid transparent',
background: isActive ? `${color}15` : 'transparent',
color: isActive ? color : '#475569',
transition: 'all 0.12s',
}}
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; }}}
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'transparent'; }}}
>
{cat}
<span style={{ marginLeft: '0.35rem', opacity: 0.6, fontWeight: '400' }}>
{categoryCounts[cat] ?? 0}
</span>
</button>
);
})}
</div>
</div>
{/* ── Error state ──────────────────────────────────────────── */}
{error && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.875rem 1rem',
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.5rem', color: '#F87171',
fontFamily: 'monospace', fontSize: '0.78rem',
}}>
<AlertCircle style={{ width: '15px', height: '15px', flexShrink: 0 }} />
{error}
<button
onClick={fetchArticles}
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: '#F87171', fontFamily: 'monospace', fontSize: '0.72rem' }}
>
Retry
</button>
</div>
)}
{/* ── Loading state ────────────────────────────────────────── */}
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<Loader style={{ width: '28px', height: '28px', color: GREEN, animation: 'spin 1s linear infinite' }} />
</div>
)}
{/* ── Article grid ─────────────────────────────────────────── */}
{!loading && !error && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: '0.875rem',
}}>
{filtered.length === 0 ? (
<EmptyState hasFilter={hasFilter} onClear={clearFilters} />
) : (
filtered.map(article => (
<ArticleCard
key={article.id}
article={article}
selected={selected?.id === article.id}
onSelect={a => setSelected(selected?.id === a.id ? null : a)}
onDelete={handleDelete}
canDelete={canWrite()}
/>
))
)}
</div>
)}
{/* ── Inline viewer ────────────────────────────────────────── */}
{selected && (
<div style={{ marginTop: '0.25rem' }}>
<KnowledgeBaseViewer
article={selected}
onClose={() => setSelected(null)}
/>
</div>
)}
{/* ── Upload modal ─────────────────────────────────────────── */}
{showUpload && (
<KnowledgeBaseModal
onClose={() => setShowUpload(false)}
onUpdate={() => { fetchArticles(); setShowUpload(false); }}
/>
)}
</div>
);
} }