Files
cve-dashboard/frontend/src/components/UserGuideModal.js
Jordan Ramos 7866a8577a Fix User Guide TOC — add heading IDs and smooth scroll anchor links
Headings now render with id attributes derived from their text so
TOC anchor links (#getting-started, etc.) have targets. Internal
anchor clicks use scrollIntoView with smooth behavior instead of
full page navigation.
2026-06-24 17:52:37 -06:00

200 lines
8.9 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { X, BookOpen, Search } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
// ⚠️ CONVENTION: Avoid hardcoded absolute URL fallback; use relative API path or ensure REACT_APP_API_BASE is always set at build time
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// Inline styles matching the dark tactical theme
const styles = {
overlay: {
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0, 0, 0, 0.8)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
},
modal: {
width: '90vw', maxWidth: '900px', height: '85vh',
background: 'linear-gradient(135deg, #0F1A2E 0%, #1E293B 100%)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.75rem',
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.7)',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
},
header: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.5rem',
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
flexShrink: 0,
},
headerTitle: {
display: 'flex', alignItems: 'center', gap: '0.5rem',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '1rem', fontWeight: 700, color: '#0EA5E9',
textTransform: 'uppercase', letterSpacing: '0.1em',
},
closeBtn: {
background: 'none', border: 'none', color: '#94A3B8',
cursor: 'pointer', padding: '0.25rem', lineHeight: 1,
},
searchBar: {
padding: '0.75rem 1.5rem',
borderBottom: '1px solid rgba(14, 165, 233, 0.1)',
flexShrink: 0,
},
searchInput: {
width: '100%', padding: '0.5rem 0.75rem 0.5rem 2.25rem',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '0.375rem',
color: '#E2E8F0', fontSize: '0.85rem',
outline: 'none',
},
searchIcon: {
position: 'absolute', left: '2.25rem', top: '50%',
transform: 'translateY(-50%)', color: '#475569',
},
content: {
flex: 1, overflow: 'auto', padding: '1.5rem 2rem',
},
toc: {
padding: '1rem 1.5rem', borderBottom: '1px solid rgba(14, 165, 233, 0.1)',
maxHeight: '200px', overflow: 'auto', flexShrink: 0,
},
tocLink: {
display: 'block', padding: '0.2rem 0', fontSize: '0.8rem',
color: '#7DD3FC', textDecoration: 'none', cursor: 'pointer',
},
};
// Static user guide content embedded at build time
const USER_GUIDE_CONTENT = `GUIDE_PLACEHOLDER`;
export default function UserGuideModal({ isOpen, onClose }) {
const [content, setContent] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!isOpen) return;
setLoading(true);
// ⚠️ CONVENTION: Use credentials: 'include' on all fetch calls for cookie-based auth consistency
fetch('/user-guide.md')
.then(res => {
if (res.ok) return res.text();
throw new Error('Not found');
})
.then(text => { setContent(text); setLoading(false); })
.catch(() => {
// Fallback: try API endpoint
fetch(`${API_BASE}/knowledge-base/user-guide`, { credentials: 'include' })
.then(res => res.ok ? res.text() : Promise.reject())
.then(text => { setContent(text); setLoading(false); })
.catch(() => { setContent('# User Guide\n\nThe user guide could not be loaded. Contact your administrator.'); setLoading(false); });
});
}, [isOpen]);
if (!isOpen) return null;
// Filter content by search term (highlight sections containing the term)
const displayContent = searchTerm
? content.split('\n').filter((line, i, arr) => {
// Show headers and lines within 3 lines of a match
if (line.toLowerCase().includes(searchTerm.toLowerCase())) return true;
for (let j = Math.max(0, i - 3); j <= Math.min(arr.length - 1, i + 3); j++) {
if (arr[j].toLowerCase().includes(searchTerm.toLowerCase())) return true;
}
return line.startsWith('#');
}).join('\n')
: content;
return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={styles.header}>
<div style={styles.headerTitle}>
<BookOpen size={18} />
User Guide
</div>
<button style={styles.closeBtn} onClick={onClose}
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
onMouseLeave={e => e.currentTarget.style.color = '#94A3B8'}
>
<X size={20} />
</button>
</div>
{/* Search */}
<div style={{ ...styles.searchBar, position: 'relative' }}>
<Search size={14} style={styles.searchIcon} />
<input
type="text"
placeholder="Search the guide..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
style={styles.searchInput}
/>
</div>
{/* Content */}
<div style={styles.content} className="user-guide-content">
{loading ? (
<p style={{ color: '#94A3B8', textAlign: 'center', marginTop: '2rem' }}>Loading guide...</p>
) : (
<ReactMarkdown
components={{
h1: ({ children }) => {
const id = String(children).toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
return <h1 id={id} style={{ fontSize: '1.5rem', fontWeight: 700, color: '#F8FAFC', marginBottom: '1rem', borderBottom: '1px solid rgba(14,165,233,0.2)', paddingBottom: '0.5rem' }}>{children}</h1>;
},
h2: ({ children }) => {
const id = String(children).toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
return <h2 id={id} style={{ fontSize: '1.2rem', fontWeight: 600, color: '#0EA5E9', marginTop: '2rem', marginBottom: '0.75rem' }}>{children}</h2>;
},
h3: ({ children }) => {
const id = String(children).toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
return <h3 id={id} style={{ fontSize: '1rem', fontWeight: 600, color: '#7DD3FC', marginTop: '1.5rem', marginBottom: '0.5rem' }}>{children}</h3>;
},
a: ({ href, children }) => {
// Handle internal anchor links — scroll within the modal
if (href && href.startsWith('#')) {
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
const targetId = href.slice(1);
const el = document.getElementById(targetId);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
style={{ color: '#7DD3FC', textDecoration: 'none', borderBottom: '1px solid rgba(125,211,252,0.3)' }}
>
{children}
</a>
);
}
return <a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#7DD3FC', textDecoration: 'none' }}>{children}</a>;
},
p: ({ children }) => <p style={{ color: '#CBD5E1', lineHeight: 1.7, marginBottom: '0.75rem', fontSize: '0.875rem' }}>{children}</p>,
li: ({ children }) => <li style={{ color: '#CBD5E1', marginBottom: '0.35rem', fontSize: '0.875rem', lineHeight: 1.6 }}>{children}</li>,
ul: ({ children }) => <ul style={{ paddingLeft: '1.25rem', marginBottom: '0.75rem' }}>{children}</ul>,
ol: ({ children }) => <ol style={{ paddingLeft: '1.25rem', marginBottom: '0.75rem' }}>{children}</ol>,
strong: ({ children }) => <strong style={{ color: '#F8FAFC', fontWeight: 600 }}>{children}</strong>,
table: ({ children }) => <table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '1rem', fontSize: '0.8rem' }}>{children}</table>,
th: ({ children }) => <th style={{ textAlign: 'left', padding: '0.5rem', borderBottom: '1px solid rgba(14,165,233,0.3)', color: '#0EA5E9', fontWeight: 600 }}>{children}</th>,
td: ({ children }) => <td style={{ padding: '0.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', color: '#CBD5E1' }}>{children}</td>,
hr: () => <hr style={{ border: 'none', borderTop: '1px solid rgba(14,165,233,0.15)', margin: '2rem 0' }} />,
blockquote: ({ children }) => <blockquote style={{ borderLeft: '3px solid #D97706', paddingLeft: '1rem', margin: '1rem 0', color: '#FCD34D', fontSize: '0.85rem' }}>{children}</blockquote>,
}}
>
{displayContent}
</ReactMarkdown>
)}
</div>
</div>
</div>
);
}