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.
200 lines
8.9 KiB
JavaScript
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>
|
|
);
|
|
}
|