diff --git a/frontend/src/components/pages/KnowledgeBasePage.js b/frontend/src/components/pages/KnowledgeBasePage.js index bae66ab..719e388 100644 --- a/frontend/src/components/pages/KnowledgeBasePage.js +++ b/frontend/src/components/pages/KnowledgeBasePage.js @@ -1,25 +1,484 @@ -import React from 'react'; -import { BookOpen } from 'lucide-react'; +// 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. -export default function KnowledgeBasePage() { - return ( -
-
-
- -
-

- Knowledge Base -

-

- Under construction — coming soon -

-
-
- ); +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); }} + /> + )} +
+ ); }