// 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 && (
)}
{/* ── 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); }}
/>
)}
);
}