// KnowledgeBasePage.js // 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. import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { BookOpen, Search, Upload, RefreshCw, Loader, AlertCircle, FileText, File, Trash2, X, } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import KnowledgeBaseModal from '../KnowledgeBaseModal'; import KnowledgeBaseViewer from '../KnowledgeBaseViewer'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const GREEN = '#10B981'; // --------------------------------------------------------------------------- // Static config // --------------------------------------------------------------------------- const CATEGORY_COLORS = { General: '#94A3B8', Policy: '#0EA5E9', Procedure: GREEN, 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 (
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 */}
{ext} {canDelete && ( )}
{/* Title */}
{article.title}
{/* Description */} {article.description && (
{article.description}
)} {/* Footer — category + date */}
{article.category}
{article.file_size && ( {fmtSize(article.file_size)} )} {fmtDate(article.created_at)}
); } // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- function EmptyState({ hasFilter, onClear }) { return (
{hasFilter ? 'No articles match your search' : 'No articles yet'}
{hasFilter ? ( ) : (
Upload a document to get started
)}
); } // --------------------------------------------------------------------------- // 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 (
{/* ── Page header ─────────────────────────────────────────── */}

Knowledge Base

{loading ? '…' : `${articles.length} article${articles.length !== 1 ? 's' : ''}`} {articles.length > 0 && activeCategory !== 'All' && ( · {categoryCounts[activeCategory] || 0} in {activeCategory} )}
{canWrite() && ( )}
{/* ── Search + category tabs ───────────────────────────────── */}
{/* Search */}
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 && ( )}
{/* Category tabs */}
{activeTabs.map(cat => { const isActive = activeCategory === cat; const color = cat === 'All' ? GREEN : catColor(cat); return ( ); })}
{/* ── Error state ──────────────────────────────────────────── */} {error && (
{error}
)} {/* ── Loading state ────────────────────────────────────────── */} {loading && (
)} {/* ── Article grid ─────────────────────────────────────────── */} {!loading && !error && (
{filtered.length === 0 ? ( ) : ( filtered.map(article => ( setSelected(selected?.id === a.id ? null : a)} onDelete={handleDelete} canDelete={canWrite()} /> )) )}
)} {/* ── Inline viewer ────────────────────────────────────────── */} {selected && (
setSelected(null)} />
)} {/* ── Upload modal ─────────────────────────────────────────── */} {showUpload && ( setShowUpload(false)} onUpdate={() => { fetchArticles(); setShowUpload(false); }} /> )}
); }