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:
170
frontend/src/components/UserGuideModal.js
Normal file
170
frontend/src/components/UserGuideModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user