2026-04-02 13:55:51 -06:00
// 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.
2026-03-11 11:47:03 -06:00
2026-04-02 13:55:51 -06:00
import React , { useState , useEffect , useCallback , useMemo } from 'react' ;
import {
BookOpen , Search , Upload , RefreshCw , Loader ,
2026-04-20 21:54:37 +00:00
AlertCircle , FileText , File , Trash2 , X , // ⚠️ CONVENTION: FileText and File are imported but unused — remove if not needed
2026-04-02 13:55:51 -06:00
} from 'lucide-react' ;
import { useAuth } from '../../contexts/AuthContext' ;
import KnowledgeBaseModal from '../KnowledgeBaseModal' ;
import KnowledgeBaseViewer from '../KnowledgeBaseViewer' ;
2026-04-20 21:54:37 +00:00
import ConfirmModal from '../ConfirmModal' ; // ⚠️ CONVENTION: ConfirmModal is imported but never used — either integrate it into handleDelete or remove this import
2026-04-02 13:55:51 -06:00
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 (
< 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 }
< / s p a n >
{ 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' } } / >
< / b u t t o n >
) }
< / d i v >
{ /* Title */ }
< div style = { {
fontFamily : 'monospace' , fontSize : '0.82rem' , fontWeight : '700' ,
color : selected ? GREEN : '#E2E8F0' ,
lineHeight : 1.3 ,
} } >
{ article . title }
< / d i v >
{ /* Description */ }
{ article . description && (
< div style = { {
fontSize : '0.7rem' , color : '#475569' ,
lineHeight : 1.45 , display : '-webkit-box' ,
WebkitLineClamp : 2 , WebkitBoxOrient : 'vertical' ,
overflow : 'hidden' ,
} } >
{ article . description }
< / d i v >
) }
{ /* 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 }
< / s p a n >
< 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 ) }
< / s p a n >
) }
< span style = { { fontFamily : 'monospace' , fontSize : '0.6rem' , color : '#334155' } } >
{ fmtDate ( article . created _at ) }
< / s p a n >
< / d i v >
< / d i v >
< / d i v >
) ;
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function EmptyState ( { hasFilter , onClear } ) {
return (
2026-03-11 11:47:03 -06:00
< div style = { {
2026-04-02 13:55:51 -06:00
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' ,
2026-03-11 11:47:03 -06:00
} } >
2026-04-02 13:55:51 -06:00
< 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' }
< / d i v >
{ hasFilter ? (
< button onClick = { onClear } style = { {
background : 'none' , border : 'none' , cursor : 'pointer' ,
color : GREEN , fontFamily : 'monospace' , fontSize : '0.72rem' ,
marginTop : '0.375rem' ,
} } >
Clear filters
< / b u t t o n >
) : (
< div style = { { fontFamily : 'monospace' , fontSize : '0.7rem' , color : '#475569' } } >
Upload a document to get started
< / d i v >
) }
< / d i v >
) ;
}
// ---------------------------------------------------------------------------
// 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 ) ;
2026-04-20 21:54:37 +00:00
const [ pendingConfirm , setPendingConfirm ] = useState ( null ) ;
2026-04-02 13:55:51 -06:00
// -------------------------------------------------------------------------
// 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 ) => {
2026-04-20 21:54:37 +00:00
setPendingConfirm ( {
title : 'Delete Article' ,
message : ` Delete " ${ article . title } "? This cannot be undone. ` ,
confirmText : 'Delete' ,
onConfirm : async ( ) => {
setPendingConfirm ( null ) ;
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 } ` ) ;
}
} ,
} ) ;
2026-04-02 13:55:51 -06:00
} , [ 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
< / h 2 >
< 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 }
< / s p a n >
) }
< / d i v >
< / d i v >
< 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' } } / >
< / b u t t o n >
{ 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
< / b u t t o n >
) }
< / d i v >
< / d i v >
{ /* ── 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' } } / >
< / b u t t o n >
) }
< / d i v >
{ /* 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 }
< / s p a n >
< / b u t t o n >
) ;
} ) }
< / d i v >
< / d i v >
{ /* ── 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
< / b u t t o n >
< / d i v >
) }
{ /* ── Loading state ────────────────────────────────────────── */ }
{ loading && (
< div style = { { display : 'flex' , justifyContent : 'center' , padding : '3rem' } } >
< Loader style = { { width : '28px' , height : '28px' , color : GREEN , animation : 'spin 1s linear infinite' } } / >
< / d i v >
) }
{ /* ── 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 ( ) }
/ >
) )
) }
< / d i v >
) }
{ /* ── Inline viewer ────────────────────────────────────────── */ }
{ selected && (
< div style = { { marginTop : '0.25rem' } } >
< KnowledgeBaseViewer
article = { selected }
onClose = { ( ) => setSelected ( null ) }
/ >
< / d i v >
) }
{ /* ── Upload modal ─────────────────────────────────────────── */ }
{ showUpload && (
< KnowledgeBaseModal
onClose = { ( ) => setShowUpload ( false ) }
onUpdate = { ( ) => { fetchArticles ( ) ; setShowUpload ( false ) ; } }
/ >
) }
2026-04-20 21:54:37 +00:00
{ /* Confirmation Modal */ }
< ConfirmModal
open = { ! ! pendingConfirm }
title = { pendingConfirm ? . title }
message = { pendingConfirm ? . message }
confirmText = { pendingConfirm ? . confirmText }
variant = "danger"
onConfirm = { pendingConfirm ? . onConfirm }
onCancel = { ( ) => setPendingConfirm ( null ) }
/ >
2026-03-11 11:47:03 -06:00
< / d i v >
2026-04-02 13:55:51 -06:00
) ;
2026-03-11 11:47:03 -06:00
}