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:
@@ -6,7 +6,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FileText, Plus, Edit, Copy, Trash2, ChevronDown, ChevronRight,
|
||||
Loader, AlertCircle, RefreshCw,
|
||||
Loader, AlertCircle, RefreshCw, Eye, EyeOff, Clipboard, Check,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
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';
|
||||
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -223,6 +235,11 @@ export default function ArcherTemplatePage() {
|
||||
// Modal state for create/edit/clone
|
||||
const [modalState, setModalState] = useState({ open: false, mode: 'create', template: 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
|
||||
@@ -265,6 +282,41 @@ export default function ArcherTemplatePage() {
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -363,36 +415,126 @@ export default function ArcherTemplatePage() {
|
||||
<div key={platform} style={STYLES.platformSubgroup}>
|
||||
<div style={STYLES.platformLabel}>{platform}</div>
|
||||
{platTemplates.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
style={STYLES.templateRow}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span style={STYLES.templateModel}>{template.model}</span>
|
||||
{canWrite() && (
|
||||
<div style={STYLES.templateActions}>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'edit', template })}
|
||||
title="Edit template"
|
||||
>
|
||||
<Edit size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'clone', template })}
|
||||
title="Clone template"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnDanger}
|
||||
onClick={() => setDeleteTarget(template)}
|
||||
title="Delete template"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<div key={template.id}>
|
||||
<div
|
||||
style={STYLES.templateRow}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span
|
||||
style={{ ...STYLES.templateModel, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
onClick={() => toggleView(template.id)}
|
||||
title="View template sections"
|
||||
>
|
||||
{viewExpandedId === template.id
|
||||
? <EyeOff size={13} style={{ color: '#00d4ff' }} />
|
||||
: <Eye size={13} style={{ color: '#64748B' }} />
|
||||
}
|
||||
{template.model}
|
||||
</span>
|
||||
{canWrite() && (
|
||||
<div style={STYLES.templateActions}>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'edit', template })}
|
||||
title="Edit template"
|
||||
>
|
||||
<Edit size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'clone', template })}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user