Add in-app User Guide modal accessible from user menu

- UserGuideModal: full-screen overlay rendering user-guide.md as
  styled markdown with search filtering
- UserMenu: add 'User Guide' item with BookOpen icon between
  Feature Request and Sign Out
- Serve user-guide.md as static file from frontend/public/
- Guide renders with dark theme styling matching the dashboard
  aesthetic (blue headers, slate text, amber blockquotes)
This commit is contained in:
Jordan Ramos
2026-06-24 17:48:30 -06:00
parent fea2127893
commit 627d957c96
3 changed files with 731 additions and 1 deletions

View File

@@ -0,0 +1,170 @@
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 }) => <h1 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 }) => <h2 style={{ fontSize: '1.2rem', fontWeight: 600, color: '#0EA5E9', marginTop: '2rem', marginBottom: '0.75rem' }}>{children}</h2>,
h3: ({ children }) => <h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#7DD3FC', marginTop: '1.5rem', marginBottom: '0.5rem' }}>{children}</h3>,
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>
);
}

View File

@@ -1,7 +1,8 @@
import React, { useState, useRef, useEffect } from 'react';
import { User, LogOut, ChevronDown, Shield, Clock, Lightbulb } from 'lucide-react';
import { User, LogOut, ChevronDown, Shield, Clock, Lightbulb, BookOpen } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import UserProfilePanel from './UserProfilePanel';
import UserGuideModal from './UserGuideModal';
// ============================================
// INLINE STYLES — Dark theme matching DESIGN_SYSTEM.md
@@ -157,6 +158,7 @@ export default function UserMenu({ onManageUsers, onAuditLog, onFeatureRequest }
const [buttonHovered, setButtonHovered] = useState(false);
const [hoveredItem, setHoveredItem] = useState(null);
const [showProfile, setShowProfile] = useState(false);
const [showGuide, setShowGuide] = useState(false);
const menuRef = useRef(null);
// Close menu when clicking outside
@@ -294,6 +296,19 @@ export default function UserMenu({ onManageUsers, onAuditLog, onFeatureRequest }
Feature Request
</button>
<button
onClick={() => { setIsOpen(false); setShowGuide(true); }}
onMouseEnter={() => setHoveredItem('guide')}
onMouseLeave={() => setHoveredItem(null)}
style={{
...STYLES.menuItem,
...(hoveredItem === 'guide' ? STYLES.menuItemHover : {}),
}}
>
<BookOpen size={16} />
User Guide
</button>
<button
onClick={handleLogout}
onMouseEnter={() => setHoveredItem('signout')}
@@ -309,6 +324,7 @@ export default function UserMenu({ onManageUsers, onAuditLog, onFeatureRequest }
</div>
)}
<UserProfilePanel isOpen={showProfile} onClose={() => setShowProfile(false)} />
<UserGuideModal isOpen={showGuide} onClose={() => setShowGuide(false)} />
</div>
);
}