2026-06-02 16:08:25 -06:00
|
|
|
// ArcherTemplatePage.js
|
|
|
|
|
// Full-page Template Manager — browse, create, edit, clone, and delete
|
|
|
|
|
// Archer Risk Acceptance templates organized by Vendor > Platform > Model.
|
|
|
|
|
// Write operations require editor/admin role (Standard_User or Admin group).
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
FileText, Plus, Edit, Copy, Trash2, ChevronDown, ChevronRight,
|
2026-06-03 11:04:25 -06:00
|
|
|
Loader, AlertCircle, RefreshCw, Eye, EyeOff, Clipboard, Check,
|
2026-06-02 16:08:25 -06:00
|
|
|
} from 'lucide-react';
|
|
|
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
|
|
|
import TemplateFormModal from '../TemplateFormModal';
|
|
|
|
|
import DeleteConfirmModal from '../DeleteConfirmModal';
|
|
|
|
|
|
|
|
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
|
|
|
|
2026-06-03 11:04:25 -06:00
|
|
|
// 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' },
|
|
|
|
|
];
|
|
|
|
|
|
2026-06-02 16:08:25 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Styles — dark theme tactical intelligence aesthetic
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
const STYLES = {
|
|
|
|
|
page: {
|
|
|
|
|
minHeight: '60vh',
|
|
|
|
|
},
|
|
|
|
|
card: {
|
|
|
|
|
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98))',
|
|
|
|
|
border: '1px solid rgba(0, 212, 255, 0.15)',
|
|
|
|
|
borderRadius: '12px',
|
|
|
|
|
padding: '1.5rem',
|
|
|
|
|
marginBottom: '1rem',
|
|
|
|
|
},
|
|
|
|
|
header: {
|
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
|
fontSize: '0.7rem',
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
color: '#00d4ff',
|
|
|
|
|
textTransform: 'uppercase',
|
|
|
|
|
letterSpacing: '0.15em',
|
|
|
|
|
marginBottom: '1rem',
|
|
|
|
|
},
|
|
|
|
|
toolbar: {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
marginBottom: '1rem',
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
gap: '0.5rem',
|
|
|
|
|
},
|
|
|
|
|
btn: {
|
|
|
|
|
padding: '0.5rem 1rem',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
border: '1px solid rgba(0, 212, 255, 0.3)',
|
|
|
|
|
background: 'rgba(0, 212, 255, 0.1)',
|
|
|
|
|
color: '#7DD3FC',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
fontSize: '0.8rem',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '0.4rem',
|
|
|
|
|
transition: 'all 0.2s',
|
|
|
|
|
},
|
|
|
|
|
btnDanger: {
|
|
|
|
|
padding: '0.4rem 0.75rem',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
|
|
|
|
background: 'rgba(239, 68, 68, 0.1)',
|
|
|
|
|
color: '#FCA5A5',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
fontSize: '0.75rem',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '0.3rem',
|
|
|
|
|
transition: 'all 0.2s',
|
|
|
|
|
},
|
|
|
|
|
btnSmall: {
|
|
|
|
|
padding: '0.35rem 0.65rem',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
border: '1px solid rgba(0, 212, 255, 0.25)',
|
|
|
|
|
background: 'rgba(0, 212, 255, 0.08)',
|
|
|
|
|
color: '#7DD3FC',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
fontSize: '0.75rem',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '0.3rem',
|
|
|
|
|
transition: 'all 0.2s',
|
|
|
|
|
},
|
|
|
|
|
vendorGroup: {
|
|
|
|
|
marginBottom: '0.75rem',
|
|
|
|
|
},
|
|
|
|
|
vendorHeader: {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '0.5rem',
|
|
|
|
|
padding: '0.6rem 0.75rem',
|
|
|
|
|
background: 'rgba(0, 212, 255, 0.05)',
|
|
|
|
|
border: '1px solid rgba(0, 212, 255, 0.12)',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
transition: 'all 0.15s',
|
|
|
|
|
},
|
|
|
|
|
vendorLabel: {
|
|
|
|
|
fontSize: '0.85rem',
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
color: '#e0e0e0',
|
|
|
|
|
},
|
|
|
|
|
vendorCount: {
|
|
|
|
|
fontSize: '0.7rem',
|
|
|
|
|
color: '#64748B',
|
|
|
|
|
marginLeft: 'auto',
|
|
|
|
|
},
|
|
|
|
|
platformSubgroup: {
|
|
|
|
|
marginLeft: '1.25rem',
|
|
|
|
|
marginTop: '0.5rem',
|
|
|
|
|
paddingLeft: '0.75rem',
|
|
|
|
|
borderLeft: '2px solid rgba(0, 212, 255, 0.1)',
|
|
|
|
|
},
|
|
|
|
|
platformLabel: {
|
|
|
|
|
fontSize: '0.78rem',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
color: '#94A3B8',
|
|
|
|
|
marginBottom: '0.35rem',
|
|
|
|
|
textTransform: 'uppercase',
|
|
|
|
|
letterSpacing: '0.05em',
|
|
|
|
|
},
|
|
|
|
|
templateRow: {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
padding: '0.45rem 0.6rem',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
marginBottom: '0.25rem',
|
|
|
|
|
transition: 'background 0.15s',
|
|
|
|
|
},
|
|
|
|
|
templateModel: {
|
|
|
|
|
fontSize: '0.82rem',
|
|
|
|
|
color: '#e0e0e0',
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
},
|
|
|
|
|
templateActions: {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '0.35rem',
|
|
|
|
|
},
|
|
|
|
|
emptyState: {
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
padding: '3rem 1rem',
|
|
|
|
|
color: '#64748B',
|
|
|
|
|
fontSize: '0.9rem',
|
|
|
|
|
},
|
|
|
|
|
errorBanner: {
|
|
|
|
|
padding: '0.75rem 1rem',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
background: 'rgba(239, 68, 68, 0.1)',
|
|
|
|
|
border: '1px solid rgba(239, 68, 68, 0.25)',
|
|
|
|
|
color: '#FCA5A5',
|
|
|
|
|
fontSize: '0.82rem',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '0.5rem',
|
|
|
|
|
marginBottom: '1rem',
|
|
|
|
|
},
|
|
|
|
|
loadingState: {
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
padding: '3rem 1rem',
|
|
|
|
|
color: '#64748B',
|
|
|
|
|
fontSize: '0.85rem',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '0.75rem',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Group templates by vendor, then by platform within each vendor.
|
|
|
|
|
* Returns: [ { vendor, platforms: [ { platform, templates: [...] } ] } ]
|
|
|
|
|
*/
|
|
|
|
|
function groupTemplates(templates) {
|
|
|
|
|
const vendorMap = {};
|
|
|
|
|
for (const t of templates) {
|
|
|
|
|
if (!vendorMap[t.vendor]) vendorMap[t.vendor] = {};
|
|
|
|
|
if (!vendorMap[t.vendor][t.platform]) vendorMap[t.vendor][t.platform] = [];
|
|
|
|
|
vendorMap[t.vendor][t.platform].push(t);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const vendors = Object.keys(vendorMap).sort((a, b) =>
|
|
|
|
|
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return vendors.map(vendor => {
|
|
|
|
|
const platforms = Object.keys(vendorMap[vendor]).sort((a, b) =>
|
|
|
|
|
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
vendor,
|
|
|
|
|
platforms: platforms.map(platform => ({
|
|
|
|
|
platform,
|
|
|
|
|
templates: vendorMap[vendor][platform].sort((a, b) =>
|
|
|
|
|
a.model.localeCompare(b.model, undefined, { sensitivity: 'base' })
|
|
|
|
|
),
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// ArcherTemplatePage
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
export default function ArcherTemplatePage() {
|
|
|
|
|
const { canWrite } = useAuth();
|
|
|
|
|
const [templates, setTemplates] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState(null);
|
|
|
|
|
const [expandedVendors, setExpandedVendors] = useState({});
|
|
|
|
|
|
|
|
|
|
// Modal state for create/edit/clone
|
|
|
|
|
const [modalState, setModalState] = useState({ open: false, mode: 'create', template: null });
|
|
|
|
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
2026-06-03 11:04:25 -06:00
|
|
|
// 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);
|
2026-06-02 16:08:25 -06:00
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// Fetch templates
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
const fetchTemplates = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${API_BASE}/archer-templates`, {
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
throw new Error(data.error || `Failed to fetch templates (${res.status})`);
|
|
|
|
|
}
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
setTemplates(data);
|
|
|
|
|
// Expand all vendors by default on initial load
|
|
|
|
|
const expanded = {};
|
|
|
|
|
const grouped = groupTemplates(data);
|
|
|
|
|
for (const g of grouped) {
|
|
|
|
|
expanded[g.vendor] = true;
|
|
|
|
|
}
|
|
|
|
|
setExpandedVendors(expanded);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err.message);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchTemplates();
|
|
|
|
|
}, [fetchTemplates]);
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// Vendor toggle
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
const toggleVendor = (vendor) => {
|
|
|
|
|
setExpandedVendors(prev => ({ ...prev, [vendor]: !prev[vendor] }));
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-03 11:04:25 -06:00
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// 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 */ }
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 16:08:25 -06:00
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// Grouped data
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
const grouped = groupTemplates(templates);
|
|
|
|
|
const totalCount = templates.length;
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// Render
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
return (
|
|
|
|
|
<div style={STYLES.page}>
|
|
|
|
|
<div style={STYLES.card}>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div style={STYLES.header}>
|
|
|
|
|
<FileText size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '0.4rem' }} />
|
|
|
|
|
Archer Template Library
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Toolbar */}
|
|
|
|
|
<div style={STYLES.toolbar}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
|
|
|
<span style={{ color: '#94A3B8', fontSize: '0.8rem' }}>
|
|
|
|
|
{totalCount} template{totalCount !== 1 ? 's' : ''}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
style={STYLES.btn}
|
|
|
|
|
onClick={fetchTemplates}
|
|
|
|
|
title="Refresh"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw size={13} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{canWrite() && (
|
|
|
|
|
<button
|
|
|
|
|
style={STYLES.btn}
|
|
|
|
|
onClick={() => setModalState({ open: true, mode: 'create', template: null })}
|
|
|
|
|
>
|
|
|
|
|
<Plus size={14} />
|
|
|
|
|
Create Template
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Error banner */}
|
|
|
|
|
{error && (
|
|
|
|
|
<div style={STYLES.errorBanner}>
|
|
|
|
|
<AlertCircle size={14} />
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Loading state */}
|
|
|
|
|
{loading && (
|
|
|
|
|
<div style={STYLES.loadingState}>
|
|
|
|
|
<Loader size={24} style={{ animation: 'spin 1s linear infinite' }} />
|
|
|
|
|
<span>Loading templates...</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Empty state */}
|
|
|
|
|
{!loading && !error && templates.length === 0 && (
|
|
|
|
|
<div style={STYLES.emptyState}>
|
|
|
|
|
<FileText size={32} style={{ marginBottom: '0.75rem', opacity: 0.4 }} />
|
|
|
|
|
<div>No templates found</div>
|
|
|
|
|
{canWrite() && (
|
|
|
|
|
<div style={{ marginTop: '0.5rem', fontSize: '0.8rem', color: '#475569' }}>
|
|
|
|
|
Click "Create Template" to add your first template.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Template list grouped by vendor > platform */}
|
|
|
|
|
{!loading && grouped.map(({ vendor, platforms }) => (
|
|
|
|
|
<div key={vendor} style={STYLES.vendorGroup}>
|
|
|
|
|
{/* Vendor header — collapsible */}
|
|
|
|
|
<div
|
|
|
|
|
style={STYLES.vendorHeader}
|
|
|
|
|
onClick={() => toggleVendor(vendor)}
|
|
|
|
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.1)'; }}
|
|
|
|
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.05)'; }}
|
|
|
|
|
>
|
|
|
|
|
{expandedVendors[vendor]
|
|
|
|
|
? <ChevronDown size={14} style={{ color: '#00d4ff' }} />
|
|
|
|
|
: <ChevronRight size={14} style={{ color: '#64748B' }} />
|
|
|
|
|
}
|
|
|
|
|
<span style={STYLES.vendorLabel}>{vendor}</span>
|
|
|
|
|
<span style={STYLES.vendorCount}>
|
|
|
|
|
{platforms.reduce((sum, p) => sum + p.templates.length, 0)} template{platforms.reduce((sum, p) => sum + p.templates.length, 0) !== 1 ? 's' : ''}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Platform subgroups */}
|
|
|
|
|
{expandedVendors[vendor] && platforms.map(({ platform, templates: platTemplates }) => (
|
|
|
|
|
<div key={platform} style={STYLES.platformSubgroup}>
|
|
|
|
|
<div style={STYLES.platformLabel}>{platform}</div>
|
|
|
|
|
{platTemplates.map(template => (
|
2026-06-03 11:04:25 -06:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-06-02 16:08:25 -06:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Template form modal (create/edit/clone) */}
|
|
|
|
|
{modalState.open && (
|
|
|
|
|
<TemplateFormModal
|
|
|
|
|
mode={modalState.mode}
|
|
|
|
|
template={modalState.template}
|
|
|
|
|
onClose={() => setModalState({ open: false, mode: 'create', template: null })}
|
|
|
|
|
onSuccess={fetchTemplates}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Delete confirmation modal */}
|
|
|
|
|
<DeleteConfirmModal
|
|
|
|
|
template={deleteTarget}
|
|
|
|
|
onConfirm={() => {
|
|
|
|
|
setDeleteTarget(null);
|
|
|
|
|
fetchTemplates();
|
|
|
|
|
}}
|
|
|
|
|
onCancel={() => setDeleteTarget(null)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|