Add copy mode switching, refactor copy section UI, and harden XSS handling

This commit is contained in:
Dariusz L
2026-06-04 18:55:12 +02:00
parent a277d3d6b6
commit 6102972ea8
3 changed files with 295 additions and 77 deletions
+198 -22
View File
@@ -75,24 +75,31 @@
}
.ml-loaded-filters {
padding: var(--ml-spacing-12) var(--ml-spacing-20) var(--ml-spacing-6);
padding: 0;
}
.ml-loaded-filter-row {
display: flex;
gap: var(--ml-spacing-8);
flex-wrap: wrap;
justify-content: flex-end;
margin-bottom: 0;
}
.ml-btn-filter,
#model-linker-modal .ml-btn-filter {
.ml-copy-mode,
.ml-btn-copy-compact,
.ml-copy-main-btn,
#model-linker-modal .ml-btn-filter,
#model-linker-modal .ml-copy-mode,
#model-linker-modal .ml-btn-copy-compact,
#model-linker-modal .ml-copy-main-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--ml-spacing-5);
min-height: var(--ml-missing-search-button-height, 34px);
height: var(--ml-missing-search-button-height, 34px);
min-height: 30px;
height: 30px;
padding: 0 12px;
color: var(--ml-text);
background: var(--ml-missing-control-bg, rgba(255,255,255,0.035));
@@ -110,7 +117,13 @@
}
.ml-btn-filter:hover:not(:disabled),
#model-linker-modal .ml-btn-filter:hover:not(:disabled) {
.ml-copy-mode:hover:not(:disabled),
.ml-btn-copy-compact:hover:not(:disabled),
.ml-copy-main-btn:hover:not(:disabled),
#model-linker-modal .ml-btn-filter:hover:not(:disabled),
#model-linker-modal .ml-copy-mode:hover:not(:disabled),
#model-linker-modal .ml-btn-copy-compact:hover:not(:disabled),
#model-linker-modal .ml-copy-main-btn:hover:not(:disabled) {
color: var(--ml-text);
background: var(--ml-missing-control-bg-hover, rgba(255,255,255,0.06));
border-color: var(--ml-missing-control-border-hover, rgba(255,255,255,0.17));
@@ -118,21 +131,31 @@
}
.ml-btn-filter:focus-visible,
#model-linker-modal .ml-btn-filter:focus-visible {
.ml-copy-mode:focus-visible,
.ml-btn-copy-compact:focus-visible,
.ml-copy-main-btn:focus-visible,
#model-linker-modal .ml-btn-filter:focus-visible,
#model-linker-modal .ml-copy-mode:focus-visible,
#model-linker-modal .ml-btn-copy-compact:focus-visible,
#model-linker-modal .ml-copy-main-btn:focus-visible {
outline: none;
border-color: var(--ml-missing-control-focus-border, rgba(114,211,138,0.30));
box-shadow: 0 0 0 2px var(--ml-missing-control-focus-ring, rgba(114,211,138,0.08));
}
.ml-btn-filter.active,
#model-linker-modal .ml-btn-filter.active {
.ml-copy-mode.active,
#model-linker-modal .ml-btn-filter.active,
#model-linker-modal .ml-copy-mode.active {
color: #dffbe5;
background: rgba(114,211,138,0.13);
border-color: rgba(114,211,138,0.32);
}
.ml-btn-filter.active:hover:not(:disabled),
#model-linker-modal .ml-btn-filter.active:hover:not(:disabled) {
.ml-copy-mode.active:hover:not(:disabled),
#model-linker-modal .ml-btn-filter.active:hover:not(:disabled),
#model-linker-modal .ml-copy-mode.active:hover:not(:disabled) {
background: rgba(114,211,138,0.17);
border-color: rgba(114,211,138,0.42);
}
@@ -143,23 +166,74 @@
.ml-model-section {
margin-bottom: var(--ml-spacing-8);
padding: var(--ml-spacing-8);
padding: var(--ml-spacing-10);
background: var(--ml-card-bg-alt, #252525);
border: 1px solid rgba(255, 255, 255, 0.055);
border-radius: var(--ml-ui-radius-sm);
}
.ml-model-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ml-spacing-10);
margin-bottom: var(--ml-spacing-6);
padding-bottom: var(--ml-spacing-5);
border-bottom: 1px solid var(--ml-color-gray-900);
}
.ml-model-section-heading {
display: flex;
align-items: baseline;
gap: var(--ml-spacing-8);
min-width: 0;
}
.ml-model-section-title {
color: var(--ml-text);
font-size: var(--ml-font-lg);
font-weight: 600;
}
.ml-model-section-total {
color: var(--ml-text-dim);
font-size: var(--ml-font-xs);
font-weight: 600;
white-space: nowrap;
}
.ml-model-section-counts {
display: flex;
align-items: center;
gap: var(--ml-spacing-5);
flex-wrap: wrap;
justify-content: flex-end;
}
.ml-model-count-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 var(--ml-spacing-7);
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.09);
color: var(--ml-text-muted);
background: rgba(255, 255, 255, 0.035);
font-size: var(--ml-font-xs);
font-weight: 700;
white-space: nowrap;
}
.ml-model-count-pill.is-active {
color: #c9f6d0;
border-color: rgba(114, 211, 138, 0.25);
background: rgba(114, 211, 138, 0.09);
}
.ml-model-count-pill.is-inactive {
color: var(--ml-text-dim);
}
.ml-model-group {
margin-bottom: var(--ml-spacing-6);
}
@@ -168,14 +242,31 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--ml-spacing-8);
margin-bottom: var(--ml-spacing-5);
}
.ml-model-group-label {
display: inline-flex;
align-items: center;
gap: var(--ml-spacing-5);
font-size: var(--ml-font-sm);
font-weight: 600;
}
.ml-model-group-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
}
.ml-model-group-count {
color: var(--ml-text-dim);
font-size: var(--ml-font-xs);
font-weight: 700;
}
.ml-model-group-label-active {
color: #4caf50;
}
@@ -185,12 +276,16 @@
}
.ml-btn-copy-compact {
padding: var(--ml-spacing-3) var(--ml-spacing-8);
font-size: var(--ml-font-xs);
font-size: 12px;
}
.ml-copy-btn-icon {
width: 13px;
height: 13px;
}
.ml-btn-copy-compact.is-muted {
opacity: 0.6;
opacity: 1;
}
.ml-model-chip-list {
@@ -204,32 +299,85 @@
}
.ml-copy-section {
padding: var(--ml-spacing-16) var(--ml-spacing-20);
position: sticky;
bottom: 0;
z-index: 2;
padding: var(--ml-spacing-10) var(--ml-spacing-12) var(--ml-spacing-12);
background: var(--ml-bg);
border-top: 1px solid var(--ml-border);
}
.ml-copy-label {
margin: 0;
font-size: var(--ml-font-md);
font-weight: 700;
color: var(--ml-text);
}
.ml-copy-meta {
margin-top: var(--ml-spacing-2);
font-size: var(--ml-font-xs);
color: var(--ml-text-muted);
}
.ml-copy-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ml-spacing-10);
margin-bottom: var(--ml-spacing-8);
}
.ml-copy-row {
.ml-copy-heading {
min-width: 0;
}
.ml-copy-actions {
display: flex;
gap: var(--ml-spacing-8);
align-items: center;
gap: var(--ml-spacing-6);
flex-wrap: wrap;
justify-content: flex-end;
}
.ml-copy-mode {
font-size: 12px;
}
.ml-copy-main-btn {
font-size: 12px;
}
.ml-copy-code {
flex: 1;
display: block;
width: 100%;
max-height: 92px;
box-sizing: border-box;
padding: var(--ml-spacing-8) var(--ml-spacing-12);
background: var(--ml-card-bg);
border-radius: var(--ml-radius-md);
font-family: "SF Mono", "Consolas", monospace;
font-size: var(--ml-font-sm);
line-height: 1.45;
color: #90caf9;
overflow-x: auto;
white-space: nowrap;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 720px) {
.ml-loaded-models-header,
.ml-copy-toolbar,
.ml-model-section-header {
align-items: stretch;
flex-direction: column;
}
.ml-loaded-filter-row,
.ml-copy-actions,
.ml-model-section-counts {
justify-content: flex-start;
}
}
.ml-error-text {
@@ -2388,21 +2536,42 @@
padding: 0;
}
.ml-loaded-models-header {
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ml-spacing-12);
padding: 10px 12px;
border-bottom: 1px solid var(--ml-border);
}
.ml-loaded-title-block {
min-width: 0;
}
.ml-loaded-models-title {
font-size: 15px;
font-weight: 600;
color: var(--ml-text);
margin: 0 0 8px 0;
margin: 0;
}
.ml-loaded-total {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
margin-left: var(--ml-spacing-6);
padding: 0 var(--ml-spacing-7);
border-radius: 999px;
background: rgba(114, 211, 138, 0.12);
color: #dffbe5;
font-size: var(--ml-font-xs);
}
.ml-loaded-models-subtitle {
margin: var(--ml-spacing-4) 0 0;
font-size: 12px;
color: var(--ml-text-muted);
}
.ml-models-list {
padding: 4px 12px 8px;
padding: 8px 12px 10px;
display: flex;
flex-direction: column;
gap: 6px;
@@ -2410,7 +2579,7 @@
.ml-model-chip {
display: inline-flex;
align-items: center;
padding: 6px 12px;
padding: 5px 10px;
background: #3a3a3a;
border-radius: 6px;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
@@ -2435,6 +2604,13 @@
margin-left: 8px;
}
@media (max-width: 720px) {
.ml-loaded-models-header {
align-items: stretch;
flex-direction: column;
}
}
.model-linker-selected {
margin: 8px 0;
padding: 10px 12px;
+96 -55
View File
@@ -1581,6 +1581,10 @@ class LinkerManagerDialog extends ComfyDialog {
.replace(/'/g, ''');
}
escapeJsString(value) {
return this.escapeHtml(JSON.stringify(String(value ?? '')));
}
truncateText(value, maxLength = 160) {
const text = String(value ?? '').trim();
if (!text) return '';
@@ -3936,14 +3940,26 @@ class LinkerManagerDialog extends ComfyDialog {
const inactiveString = buildCategoryStrings('inactive');
const allString = buildCategoryStrings('all');
const self = this;
const copyIcon = getSvgIcon('copy', 'currentColor', 'ml-copy-btn-icon');
const stripModelExtension = (value) => String(value || 'Unknown').replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const modelToken = (model, category) => {
const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
const name = stripModelExtension(fullName);
const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : '1.00';
return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`;
};
const copyButtonHtml = (text, extraClass = '') => `
<button class="ml-btn-filter ml-btn-copy-compact ${extraClass}" onclick="window.MLCopy(${this.escapeJsString(text)}, this)">
${copyIcon}<span>Copy</span>
</button>`;
const countText = (count, label) => `${count} ${label}${count === 1 ? '' : 's'}`;
let html = `
<div class="ml-loaded-models-header">
<h3 class="ml-loaded-models-title">${total} Model${total > 1 ? 's' : ''} in Workflow</h3>
<p class="ml-loaded-models-subtitle">LoraManager / LoraLoaderV2 nodes distinguish active/inactive</p>
</div>
<div class="ml-loaded-filters">
<div class="ml-loaded-title-block">
<h3 class="ml-loaded-models-title">Loaded Models <span class="ml-loaded-total">${total}</span></h3>
<p class="ml-loaded-models-subtitle">${countText(activeCount, 'active')} / ${countText(inactiveCount, 'inactive')}</p>
</div>
<div class="ml-loaded-filter-row">
<button class="ml-btn-filter active" id="filter-all" onclick="window.MLFilterSwitch('all')">All (${activeCount + inactiveCount})</button>
<button class="ml-btn-filter" id="filter-active" onclick="window.MLFilterSwitch('active')">Active (${activeCount})</button>
@@ -3957,26 +3973,28 @@ class LinkerManagerDialog extends ComfyDialog {
const displayName = this.getCategoryDisplayName(category);
const hasActive = modelsObj.active.length > 0;
const hasInactive = modelsObj.inactive.length > 0;
const sectionTotal = modelsObj.active.length + modelsObj.inactive.length;
html += `<div class="ml-model-section" data-ml-filter="all" data-ml-active="${hasActive}" data-ml-inactive="${hasInactive}">`;
// Add category header
html += `<div class="ml-model-section-header">
<span class="ml-model-section-title">${displayName.toUpperCase()}</span>
<div class="ml-model-section-heading">
<span class="ml-model-section-title">${this.escapeHtml(displayName.toUpperCase())}</span>
<span class="ml-model-section-total">${countText(sectionTotal, 'model')}</span>
</div>
<div class="ml-model-section-counts">
${hasActive ? `<span class="ml-model-count-pill is-active">${countText(modelsObj.active.length, 'active')}</span>` : ''}
${hasInactive ? `<span class="ml-model-count-pill is-inactive">${countText(modelsObj.inactive.length, 'inactive')}</span>` : ''}
</div>
</div>`;
if (hasActive) {
const activeStr = modelsObj.active.map(m => {
const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown';
let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00';
return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`;
}).join(' ');
const activeStr = modelsObj.active.map(m => modelToken(m, category)).join(' ');
html += `<div class="ml-model-group">
html += `<div class="ml-model-group ml-model-group-active">
<div class="ml-model-group-head">
<span class="ml-model-group-label ml-model-group-label-active">● ACTIVE</span>
<button class="ml-btn ml-btn-sm ml-btn-copy-compact" onclick="window.MLCopy('${activeStr.replace(/'/g, "\\'")}', this)">Copy</button>
<span class="ml-model-group-label ml-model-group-label-active"><span class="ml-model-group-dot"></span>Active <span class="ml-model-group-count">${modelsObj.active.length}</span></span>
${copyButtonHtml(activeStr)}
</div>
<div class="ml-model-chip-list">`;
@@ -3985,23 +4003,18 @@ class LinkerManagerDialog extends ComfyDialog {
const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null;
const modelData = encodeURIComponent(JSON.stringify(model));
html += `<span class="ml-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${name}${strength !== null ? `<span class="ml-model-chip-strength">${strength}</span>` : ''}</span>`;
html += `<span class="ml-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${this.escapeHtml(name)}${strength !== null ? `<span class="ml-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
}
html += `</div></div>`;
}
if (hasInactive) {
const inactiveStr = modelsObj.inactive.map(m => {
const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown';
let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00';
return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`;
}).join(' ');
const inactiveStr = modelsObj.inactive.map(m => modelToken(m, category)).join(' ');
html += `<div>
html += `<div class="ml-model-group ml-model-group-inactive">
<div class="ml-model-group-head">
<span class="ml-model-group-label ml-model-group-label-inactive">○ INACTIVE</span>
<button class="ml-btn ml-btn-sm ml-btn-copy-compact is-muted" onclick="window.MLCopy('${inactiveStr.replace(/'/g, "\\'")}', this)">Copy</button>
<span class="ml-model-group-label ml-model-group-label-inactive"><span class="ml-model-group-dot"></span>Inactive <span class="ml-model-group-count">${modelsObj.inactive.length}</span></span>
${copyButtonHtml(inactiveStr, 'is-muted')}
</div>
<div class="ml-model-chip-list ml-model-chip-list-inactive">`;
@@ -4010,7 +4023,7 @@ class LinkerManagerDialog extends ComfyDialog {
const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null;
const modelData = encodeURIComponent(JSON.stringify(model));
html += `<span class="ml-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${name}${strength !== null ? `<span class="ml-model-chip-strength">${strength}</span>` : ''}</span>`;
html += `<span class="ml-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${this.escapeHtml(name)}${strength !== null ? `<span class="ml-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
}
html += `</div></div>`;
}
@@ -4021,12 +4034,20 @@ class LinkerManagerDialog extends ComfyDialog {
const copySectionId = 'ml-copy-' + Date.now();
html += `
</div>
<div id="${copySectionId}" class="ml-copy-section" data-ml-active="${this.escapeHtml(activeString)}" data-ml-inactive="${this.escapeHtml(inactiveString)}" data-ml-all="${this.escapeHtml(allString)}">
<div class="ml-copy-label" id="${copySectionId}-label">Copy all:</div>
<div class="ml-copy-row">
<code class="ml-copy-code" id="${copySectionId}-code">${this.escapeHtml(allString)}</code>
<button class="ml-btn ml-btn-secondary" onclick="window.MLCopyCode('${copySectionId}', this)">Copy</button>
<div id="${copySectionId}" class="ml-copy-section" data-ml-active="${this.escapeHtml(activeString)}" data-ml-inactive="${this.escapeHtml(inactiveString)}" data-ml-all="${this.escapeHtml(allString)}" data-ml-active-count="${activeCount}" data-ml-inactive-count="${inactiveCount}" data-ml-all-count="${activeCount + inactiveCount}">
<div class="ml-copy-toolbar">
<div class="ml-copy-heading">
<div class="ml-copy-label" id="${copySectionId}-label">Copy all</div>
<div class="ml-copy-meta" id="${copySectionId}-meta">${countText(activeCount + inactiveCount, 'token')}</div>
</div>
<div class="ml-copy-actions">
<button type="button" class="ml-btn-filter ml-copy-mode active" data-ml-copy-mode="all" onclick="window.MLSetCopyMode('${copySectionId}', 'all', this)">All</button>
<button type="button" class="ml-btn-filter ml-copy-mode" data-ml-copy-mode="active" onclick="window.MLSetCopyMode('${copySectionId}', 'active', this)">Active</button>
<button type="button" class="ml-btn-filter ml-copy-mode" data-ml-copy-mode="inactive" onclick="window.MLSetCopyMode('${copySectionId}', 'inactive', this)">Inactive</button>
<button class="ml-btn-filter ml-copy-main-btn" onclick="window.MLCopyCode('${copySectionId}', this)">${copyIcon}<span>Copy</span></button>
</div>
</div>
<code class="ml-copy-code" id="${copySectionId}-code">${this.escapeHtml(allString)}</code>
</div>
`;
@@ -8036,6 +8057,44 @@ window.MLToggleHidden = function(id, trigger, collapsedText, expandedText) {
}
};
window.MLSetCopyMode = function(sectionId, mode) {
const section = document.getElementById(sectionId);
if (!section) return;
const normalizedMode = ['all', 'active', 'inactive'].includes(mode) ? mode : 'all';
const codeEl = section.querySelector('.ml-copy-code');
const labelEl = section.querySelector('.ml-copy-label');
const metaEl = section.querySelector('.ml-copy-meta');
const dataKey = normalizedMode === 'all' ? 'mlAll' : normalizedMode === 'active' ? 'mlActive' : 'mlInactive';
const countKey = normalizedMode === 'all' ? 'mlAllCount' : normalizedMode === 'active' ? 'mlActiveCount' : 'mlInactiveCount';
const labelMap = {
all: 'Copy all',
active: 'Copy active',
inactive: 'Copy inactive'
};
if (codeEl) codeEl.textContent = section.dataset[dataKey] || '';
if (labelEl) labelEl.textContent = labelMap[normalizedMode];
if (metaEl) {
const count = Number(section.dataset[countKey] || 0);
metaEl.textContent = `${count} token${count === 1 ? '' : 's'}`;
}
section.querySelectorAll('.ml-copy-mode').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mlCopyMode === normalizedMode);
});
};
window.MLSetCopyButtonFeedback = function(btn, text) {
if (!btn) return;
const originalHtml = btn.dataset.mlOriginalHtml || btn.innerHTML;
btn.dataset.mlOriginalHtml = originalHtml;
btn.textContent = text;
setTimeout(() => {
if (btn.isConnected) btn.innerHTML = originalHtml;
}, 1500);
};
window.MLFilterSwitch = function(filter) {
const filterBtn = document.getElementById('filter-' + filter);
if (!filterBtn) return;
@@ -8049,10 +8108,8 @@ window.MLFilterSwitch = function(filter) {
const hasActive = s.getAttribute('data-ml-active') === 'true';
const hasInactive = s.getAttribute('data-ml-inactive') === 'true';
// Get child divs: category header, active section, inactive section
const childDivs = Array.from(s.children).filter(c => c.tagName === 'DIV');
const activeSection = childDivs[1];
const inactiveSection = childDivs[2];
const activeSection = s.querySelector('.ml-model-group-active');
const inactiveSection = s.querySelector('.ml-model-group-inactive');
if (filter === 'all') {
s.style.display = 'block';
@@ -8071,27 +8128,13 @@ window.MLFilterSwitch = function(filter) {
const copySection = document.querySelector('[id^="ml-copy-"]');
if (copySection) {
const codeEl = copySection.querySelector('code');
const labelEl = copySection.querySelector('div');
if (filter === 'all') {
codeEl.textContent = copySection.dataset.mlAll;
labelEl.textContent = 'Copy all:';
} else if (filter === 'active') {
codeEl.textContent = copySection.dataset.mlActive;
labelEl.textContent = 'Copy active:';
} else if (filter === 'inactive') {
codeEl.textContent = copySection.dataset.mlInactive;
labelEl.textContent = 'Copy inactive:';
}
window.MLSetCopyMode(copySection.id, filter);
}
};
window.MLCopy = function(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = '✓';
setTimeout(() => btn.textContent = orig, 1500);
window.MLSetCopyButtonFeedback(btn, 'Copied');
});
};
@@ -8099,9 +8142,7 @@ window.MLCopyCode = function(sectionId, btn) {
const section = document.getElementById(sectionId);
const codeEl = section.querySelector('code');
navigator.clipboard.writeText(codeEl.textContent).then(() => {
const orig = btn.textContent;
btn.textContent = '✓ Copied!';
setTimeout(() => btn.textContent = orig, 1500);
window.MLSetCopyButtonFeedback(btn, 'Copied');
});
};
+1
View File
@@ -6,6 +6,7 @@ const SVG_ICONS = {
refreshCw: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path></svg>`,
locate: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12h3"></path><path d="M19 12h3"></path><path d="M12 2v3"></path><path d="M12 19v3"></path><circle cx="12" cy="12" r="7"></circle><circle cx="12" cy="12" r="2.5"></circle></svg>`,
download: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"></path><path d="m7 10 5 5 5-5"></path><path d="M5 21h14"></path></svg>`,
copy: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`,
externalLink: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8.5 12h10.5"></path><path d="m15.25 15.75 3.75-3.75-3.75-3.75"></path><path d="M19 7.75V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-.75"></path></svg>`,
internalLink: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8.5 12h10.5"></path><path d="M12.25 15.75 8.5 12l3.75-3.75"></path><path d="M19 7.75V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-.75"></path></svg>`,
database: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="8" ry="3"></ellipse><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"></path><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path></svg>`,