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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user