// ============================================================ // ICON MANAGER // ============================================================ let iconManagerData = []; let iconManagerPath = ''; let iconManagerSortMode = 'alpha'; let iconManagerSearchTerm = ''; let iconManagerHighlightedKeys = new Set(); let iconManagerHighlightTimer = null; const ICONIFY_IMPORT_SETS = { lucide: { label: 'Lucide', license: 'ISC', licenseUrl: 'https://github.com/lucide-icons/lucide/blob/main/LICENSE' }, tabler: { label: 'Tabler Icons', license: 'MIT', licenseUrl: 'https://github.com/tabler/tabler-icons/blob/master/LICENSE' }, heroicons:{ label: 'Heroicons', license: 'MIT', licenseUrl: 'https://github.com/tailwindlabs/heroicons/blob/master/LICENSE' }, ph: { label: 'Phosphor', license: 'MIT', licenseUrl: 'https://github.com/phosphor-icons/core/blob/main/LICENSE' }, bi: { label: 'Bootstrap Icons',license: 'MIT', licenseUrl: 'https://github.com/twbs/icons/blob/main/LICENSE.md', style: 'fill' }, iconoir: { label: 'Iconoir', license: 'MIT', licenseUrl: 'https://github.com/iconoir-icons/iconoir/blob/main/LICENSE' }, ion: { label: 'Ionicons', license: 'MIT', licenseUrl: 'https://github.com/ionic-team/ionicons/blob/main/LICENSE' }, mynaui: { label: 'Myna UI', license: 'MIT', licenseUrl: 'https://github.com/MynaUI/icons/blob/main/LICENSE' }, mingcute: { label: 'MingCute', license: 'Apache-2.0', licenseUrl: 'https://github.com/mingcute-design/mingcute-icons/blob/main/LICENSE' }, tdesign: { label: 'TDesign Icons', license: 'MIT', licenseUrl: 'https://github.com/Tencent/tdesign-icons/blob/main/LICENSE' } }; async function fetchJsonWithTimeout(url, options = {}, timeoutMs = 12000) { const controller = new AbortController(); const timer = setTimeout(function() { controller.abort(); }, timeoutMs); try { const response = await fetch(url, Object.assign({}, options, { signal: controller.signal, cache: 'no-store', })); return await response.json(); } finally { clearTimeout(timer); } } function cleanSvgAttributes(attrs) { if (!attrs) return ''; const allowedAttrs = new Set([ 'd', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'width', 'height', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'fill-rule', 'clip-rule', 'opacity', 'fill-opacity', 'stroke-opacity', 'transform', 'id', 'clip-path', 'mask', 'offset', 'stop-color', 'stop-opacity', 'gradientUnits', 'gradientTransform' ]); let cleaned = ''; attrs.replace(/\s+([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>\/]+))?/g, function(match, rawName, rawValue) { const name = rawName.replace(/^xlink:/i, '').trim(); const lower = name.toLowerCase(); if (lower.startsWith('on') || lower === 'style' || lower === 'class') return ''; if (!allowedAttrs.has(name) && !allowedAttrs.has(lower)) return ''; const attrName = allowedAttrs.has(name) ? name : lower; let value = rawValue || ''; if (value) { const unquoted = value.replace(/^["']|["']$/g, '').trim(); if ((lower === 'clip-path' || lower === 'mask') && unquoted && !/^url\(#[-_a-zA-Z0-9]+\)$/.test(unquoted)) return ''; if ((lower === 'fill' || lower === 'stroke') && /^url\(/i.test(unquoted) && !/^url\(#[-_a-zA-Z0-9]+\)$/i.test(unquoted)) return ''; value = '="' + escapeHtml(unquoted) + '"'; } cleaned += ' ' + attrName + value; return ''; }); return cleaned; } function sanitizeIconSvgClient(svg) { svg = (svg || '').trim() .replace(/<\?xml[\s\S]*?\?>/gi, '') .replace(//gi, '') .replace(/<\s*\/?\s*svg\b[^>]*>/gi, '') .replace(//g, '') .replace(/<\s*script\b[^>]*>[\s\S]*?<\s*\/\s*script\s*>/gi, '') .replace(/<\s*(metadata|desc|style|sodipodi:namedview)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '') .replace(/<\s*defs\b[^>]*>\s*(?:<\s*style\b[^>]*>[\s\S]*?<\s*\/\s*style\s*>\s*)*<\s*\/\s*defs\s*>/gi, '') .replace(/\s+on[a-z]+\s*=\s*(["']).*?\1/gi, '') .replace(/\s+(href|xlink:href)\s*=\s*(["'])\s*(?!#)[^"']*\2/gi, ''); const allowed = new Set(['path', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'rect', 'g', 'defs', 'clipPath', 'mask', 'linearGradient', 'radialGradient', 'stop', 'title']); svg = svg.replace(/<\s*(\/?)([a-zA-Z][a-zA-Z0-9:-]*)([^>]*)>/g, function(match, closing, tag, attrs) { if (!allowed.has(tag)) return ''; if (closing) return ``; const selfClosing = /\/\s*$/.test(attrs || ''); const cleanedAttrs = cleanSvgAttributes(attrs || ''); return `<${tag}${cleanedAttrs}${selfClosing ? '/' : ''}>`; }); svg = svg.replace(/^\s*(?:\.[\w-]+\s*\{[^}]*\}\s*)+/gm, ''); svg = removeEmptySvgContainers(svg); return svg.trim(); } function normalizeIconViewBoxClient(viewBox) { const raw = (viewBox || '').trim(); if (!raw) return '0 0 24 24'; const parts = raw.split(/[\s,]+/); if (parts.length !== 4) return '0 0 24 24'; const numbers = parts.map(Number); if (numbers.some(function(value) { return !Number.isFinite(value); }) || numbers[2] <= 0 || numbers[3] <= 0) { return '0 0 24 24'; } return numbers.map(function(value) { return Number.isInteger(value) ? String(value) : String(parseFloat(value.toFixed(6))); }).join(' '); } function extractIconViewBoxClient(svg) { const markup = String(svg || ''); const viewBoxMatch = markup.match(/<\s*svg\b[^>]*\sviewBox\s*=\s*(["'])(.*?)\1/i); if (viewBoxMatch) return normalizeIconViewBoxClient(viewBoxMatch[2]); const widthMatch = markup.match(/<\s*svg\b[^>]*\swidth\s*=\s*(["'])([0-9.]+)(?:px)?\1/i); const heightMatch = markup.match(/<\s*svg\b[^>]*\sheight\s*=\s*(["'])([0-9.]+)(?:px)?\1/i); if (widthMatch && heightMatch) return normalizeIconViewBoxClient('0 0 ' + widthMatch[2] + ' ' + heightMatch[2]); return ''; } function removeEmptySvgContainers(svg) { let previous = ''; let cleaned = svg || ''; while (cleaned !== previous) { previous = cleaned; cleaned = cleaned .replace(/]*>\s*<\/defs>/gi, '') .replace(/]*>\s*<\/g>/gi, '') .replace(/]*>\s*<\/clipPath>/gi, '') .replace(/]*>\s*<\/mask>/gi, ''); } return cleaned; } function forceIconCurrentColor(svg) { let normalized = sanitizeIconSvgClient(svg) .replace(/\s(fill|stroke)\s*=\s*(["'])(?!none\2|currentColor\2|url\(#)[^"']*\2/gi, ' $1="currentColor"') .replace(/\sstop-color\s*=\s*(["'])(?!currentColor\1|none\1)[^"']*\1/gi, ' stop-color="currentColor"'); normalized = normalized.replace(/<\s*(polygon|circle|ellipse|rect)\b([^>]*)\/?>/gi, function(match, tag, attrs) { if (/\s(?:fill|stroke)\s*=/i.test(attrs)) return match; const selfClosing = /\/\s*>$/.test(match); const cleanedAttrs = (attrs || '').replace(/\/\s*$/, ''); return `<${tag}${cleanedAttrs} fill="currentColor"${selfClosing ? '/>' : '>'}`; }); return normalized; } async function loadIconManager() { const button = document.getElementById('iconManagerRefreshBtn'); if (button) button.disabled = true; try { const result = await fetchJsonWithTimeout('api.php?action=list-icons&csrf_token=' + encodeURIComponent(CSRF_TOKEN) + '&_=' + Date.now()); if (result.success) { iconManagerData = result.data.icons || []; iconManagerPath = result.data.path || 'content/settings/iconset.json'; renderIconManager(result.data); } else { showToast(result.message, 'error'); } } catch (error) { showToast(t('icons.load_error', {message: error.message}), 'error'); } finally { if (button) button.disabled = false; } } function getFilteredIconManagerIcons() { const term = iconManagerSearchTerm.toLowerCase(); let icons = iconManagerData.slice(); if (term) { icons = icons.filter(function(iconItem) { const haystack = [ iconItem.key || '', iconItem.label || '', (iconItem.tags || []).join(' '), iconItem.source || '' ].join(' ').toLowerCase(); return haystack.includes(term); }); } icons.sort(function(a, b) { if (iconManagerSortMode === 'newest' || iconManagerSortMode === 'oldest') { const aTime = Date.parse(a.createdAt || a.updatedAt || '') || 0; const bTime = Date.parse(b.createdAt || b.updatedAt || '') || 0; if (aTime !== bTime) { return iconManagerSortMode === 'newest' ? bTime - aTime : aTime - bTime; } } return String(a.key || '').localeCompare(String(b.key || ''), undefined, { sensitivity: 'base' }); }); return icons; } function iconSvgAttributes(iconItem) { return (iconItem.style || 'stroke') === 'fill' ? 'fill="currentColor"' : 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; } function iconSvgMarkup(iconItem) { const svg = iconItem.svg || ''; return (iconItem.style || 'stroke') === 'fill' ? svg.replace(/\s+stroke\s*=\s*(["'])currentColor\1/gi, '') : svg; } function highlightIconManagerCard(key) { if (key) iconManagerHighlightedKeys.add(key); if (iconManagerHighlightTimer) clearTimeout(iconManagerHighlightTimer); if (iconManagerHighlightedKeys.size) { iconManagerHighlightTimer = setTimeout(function() { iconManagerHighlightedKeys.clear(); renderIconManager(); }, 60000); } } function renderIconManager(data = null) { const grid = document.getElementById('iconManagerGrid'); const empty = document.getElementById('iconManagerEmpty'); const path = document.getElementById('iconManagerPath'); if (!grid) return; if (data) { iconManagerData = data.icons || []; iconManagerPath = data.path || iconManagerPath; } grid.innerHTML = ''; // The site-relative path only: a server path says nothing an editor needs and reveals the install location if (path) path.textContent = t('icons.path', {path: (iconManagerPath || 'content/settings/iconset.json').replace(/^.*?(?=content\/)/, '')}); const icons = getFilteredIconManagerIcons(); if (!icons.length) { if (empty) empty.style.display = ''; renderAdminListFooter('iconManagerFooter', 'icons', 0, getDashboardPageSize('icons'), renderIconManager, 'iconManagerFooterTop'); return; } if (empty) empty.style.display = 'none'; const pageSize = getDashboardPageSize('icons'); const paged = pageSlice(icons, 'icons', pageSize); renderAdminListFooter('iconManagerFooter', 'icons', icons.length, pageSize, renderIconManager, 'iconManagerFooterTop'); paged.items.forEach(function(iconItem) { const card = document.createElement('article'); card.className = 'icon-manager-card'; if (iconManagerHighlightedKeys.has(iconItem.key)) { card.classList.add('icon-manager-card--highlight'); } const sourceLabel = iconItem.source === 'custom' ? t('icons.source_custom') : t('icons.source_core'); card.title = sourceLabel; card.dataset.iconKey = iconItem.key || ''; card.dataset.iconTags = (iconItem.tags || []).join(' '); card.innerHTML = `
${iconSvgMarkup(iconItem)}
${escapeHtml(iconItem.key)} ${escapeHtml(iconItem.label || '')}
`; grid.appendChild(card); }); grid.querySelectorAll('[data-icon-edit]').forEach(function(btn) { btn.addEventListener('click', function() { const iconItem = iconManagerData.find(function(item) { return item.key === btn.dataset.iconEdit; }); if (iconItem) openIconManagerModal(iconItem); }); }); grid.querySelectorAll('[data-icon-delete]').forEach(function(btn) { btn.addEventListener('click', function() { const iconItem = iconManagerData.find(function(item) { return item.key === btn.dataset.iconDelete; }); if (iconItem) deleteIconManagerIcon(iconItem); }); }); } function openIconManagerModal(iconItem) { const isEdit = !!iconItem; document.getElementById('iconManagerModalTitle').textContent = isEdit ? t('icons.edit_icon') : t('icons.add_icon'); document.getElementById('iconManagerOldKey').value = isEdit ? iconItem.key : ''; document.getElementById('iconManagerKey').value = isEdit ? iconItem.key : ''; document.getElementById('iconManagerLabel').value = isEdit ? (iconItem.label || '') : ''; document.getElementById('iconManagerTags').value = isEdit ? (iconItem.tags || []).join(', ') : ''; document.getElementById('iconManagerViewBox').value = isEdit ? (iconItem.viewBox || '0 0 24 24') : '0 0 24 24'; document.getElementById('iconManagerSvg').value = isEdit ? (iconItem.svg || '') : ''; document.getElementById('iconRenameWarning').hidden = true; updateIconManagerPreview(); closeAllComboboxes(); document.getElementById('iconManagerModalOverlay').style.display = 'flex'; } function closeIconManagerModal() { document.getElementById('iconManagerModalOverlay').style.display = 'none'; } // Custom icon-set picker (replaces native