Add inline view panel to Template Manager with copy buttons

Click the model name or eye icon to expand a read-only view of all
template sections with per-section copy-to-clipboard and Copy All.
This commit is contained in:
Jordan Ramos
2026-06-03 11:04:25 -06:00
parent 4f40850fd2
commit 50f14c14d2

View File

@@ -6,7 +6,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
FileText, Plus, Edit, Copy, Trash2, ChevronDown, ChevronRight, FileText, Plus, Edit, Copy, Trash2, ChevronDown, ChevronRight,
Loader, AlertCircle, RefreshCw, Loader, AlertCircle, RefreshCw, Eye, EyeOff, Clipboard, Check,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import TemplateFormModal from '../TemplateFormModal'; import TemplateFormModal from '../TemplateFormModal';
@@ -14,6 +14,18 @@ import DeleteConfirmModal from '../DeleteConfirmModal';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// Section field mapping — ordered: static first, then semi-static
const SECTIONS = [
{ key: 'environment_overview', label: 'Environment Overview' },
{ key: 'segmentation', label: 'Segmentation' },
{ key: 'mitigating_controls', label: 'Mitigating Controls' },
{ key: 'additional_info', label: 'Additional Info/Background' },
{ key: 'charter_network_banner', label: 'Charter Network Banner' },
{ key: 'data_classification', label: 'Data Classification' },
{ key: 'charter_network', label: 'Charter Network' },
{ key: 'additional_access_list', label: 'Additional Access List' },
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Styles — dark theme tactical intelligence aesthetic // Styles — dark theme tactical intelligence aesthetic
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -223,6 +235,11 @@ export default function ArcherTemplatePage() {
// Modal state for create/edit/clone // Modal state for create/edit/clone
const [modalState, setModalState] = useState({ open: false, mode: 'create', template: null }); const [modalState, setModalState] = useState({ open: false, mode: 'create', template: null });
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
// View panel state — which template ID is expanded for viewing
const [viewExpandedId, setViewExpandedId] = useState(null);
// Copy state for view panel
const [copiedSections, setCopiedSections] = useState({});
const [copyAllCopied, setCopyAllCopied] = useState(false);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Fetch templates // Fetch templates
@@ -265,6 +282,41 @@ export default function ArcherTemplatePage() {
setExpandedVendors(prev => ({ ...prev, [vendor]: !prev[vendor] })); setExpandedVendors(prev => ({ ...prev, [vendor]: !prev[vendor] }));
}; };
// -------------------------------------------------------------------------
// View panel toggle and copy handlers
// -------------------------------------------------------------------------
const toggleView = (templateId) => {
setViewExpandedId(prev => prev === templateId ? null : templateId);
setCopiedSections({});
setCopyAllCopied(false);
};
const handleCopySection = async (sectionKey, content) => {
if (!content) return;
try {
await navigator.clipboard.writeText(content);
setCopiedSections(prev => ({ ...prev, [sectionKey]: true }));
setTimeout(() => {
setCopiedSections(prev => ({ ...prev, [sectionKey]: false }));
}, 2000);
} catch (_err) { /* clipboard failed */ }
};
const handleCopyAll = async (template) => {
const parts = [];
for (const section of SECTIONS) {
const content = template[section.key];
if (content && content.trim()) {
parts.push(`${section.label}\n${content}`);
}
}
try {
await navigator.clipboard.writeText(parts.join('\n\n'));
setCopyAllCopied(true);
setTimeout(() => setCopyAllCopied(false), 2000);
} catch (_err) { /* clipboard failed */ }
};
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Grouped data // Grouped data
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -363,36 +415,126 @@ export default function ArcherTemplatePage() {
<div key={platform} style={STYLES.platformSubgroup}> <div key={platform} style={STYLES.platformSubgroup}>
<div style={STYLES.platformLabel}>{platform}</div> <div style={STYLES.platformLabel}>{platform}</div>
{platTemplates.map(template => ( {platTemplates.map(template => (
<div <div key={template.id}>
key={template.id} <div
style={STYLES.templateRow} style={STYLES.templateRow}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }} onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
> >
<span style={STYLES.templateModel}>{template.model}</span> <span
{canWrite() && ( style={{ ...STYLES.templateModel, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
<div style={STYLES.templateActions}> onClick={() => toggleView(template.id)}
<button title="View template sections"
style={STYLES.btnSmall} >
onClick={() => setModalState({ open: true, mode: 'edit', template })} {viewExpandedId === template.id
title="Edit template" ? <EyeOff size={13} style={{ color: '#00d4ff' }} />
> : <Eye size={13} style={{ color: '#64748B' }} />
<Edit size={12} /> }
</button> {template.model}
<button </span>
style={STYLES.btnSmall} {canWrite() && (
onClick={() => setModalState({ open: true, mode: 'clone', template })} <div style={STYLES.templateActions}>
title="Clone template" <button
> style={STYLES.btnSmall}
<Copy size={12} /> onClick={() => setModalState({ open: true, mode: 'edit', template })}
</button> title="Edit template"
<button >
style={STYLES.btnDanger} <Edit size={12} />
onClick={() => setDeleteTarget(template)} </button>
title="Delete template" <button
> style={STYLES.btnSmall}
<Trash2 size={12} /> onClick={() => setModalState({ open: true, mode: 'clone', template })}
</button> title="Clone template"
>
<Copy size={12} />
</button>
<button
style={STYLES.btnDanger}
onClick={() => setDeleteTarget(template)}
title="Delete template"
>
<Trash2 size={12} />
</button>
</div>
)}
</div>
{/* Expandable view panel */}
{viewExpandedId === template.id && (
<div style={{
margin: '0.25rem 0 0.75rem 1.5rem',
padding: '0.75rem 1rem',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(0, 212, 255, 0.12)',
borderRadius: '8px',
}}>
{/* Copy All button */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
<button
onClick={() => handleCopyAll(template)}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '5px',
border: copyAllCopied ? '1px solid rgba(34, 197, 94, 0.4)' : '1px solid rgba(0, 212, 255, 0.3)',
background: copyAllCopied ? 'rgba(34, 197, 94, 0.12)' : 'rgba(0, 212, 255, 0.08)',
color: copyAllCopied ? '#22c55e' : '#00d4ff',
fontSize: '0.7rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
}}
>
{copyAllCopied ? <><Check size={11} /> Copied!</> : <><Clipboard size={11} /> Copy All</>}
</button>
</div>
{/* Section blocks */}
{SECTIONS.map(section => {
const content = template[section.key];
const isEmpty = !content || !content.trim();
const isCopied = copiedSections[section.key];
return (
<div key={section.key} style={{
marginBottom: '0.5rem',
padding: '0.5rem 0.6rem',
background: 'rgba(30, 41, 59, 0.5)',
border: '1px solid rgba(100, 116, 139, 0.12)',
borderRadius: '6px',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{section.label}
</span>
<button
onClick={() => handleCopySection(section.key, content)}
disabled={isEmpty}
style={{
padding: '0.2rem 0.4rem',
borderRadius: '4px',
border: isCopied ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid rgba(100, 116, 139, 0.25)',
background: isCopied ? 'rgba(34, 197, 94, 0.1)' : 'rgba(100, 116, 139, 0.1)',
color: isCopied ? '#22c55e' : '#94a3b8',
fontSize: '0.65rem',
cursor: isEmpty ? 'not-allowed' : 'pointer',
opacity: isEmpty ? 0.4 : 1,
display: 'flex',
alignItems: 'center',
gap: '0.2rem',
}}
>
{isCopied ? <><Check size={9} /> Copied!</> : <><Clipboard size={9} /> Copy</>}
</button>
</div>
{isEmpty ? (
<div style={{ fontSize: '0.78rem', color: '#475569', fontStyle: 'italic' }}>No content stored</div>
) : (
<div style={{ fontSize: '0.78rem', color: '#e0e0e0', lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: '150px', overflowY: 'auto' }}>
{content}
</div>
)}
</div>
);
})}
</div> </div>
)} )}
</div> </div>