ae66c2e34c
Add 50,000+ icon font package sourced from Flaticon API with local webfonts and interactive icon explorer. Features: - 50,492 icons across 15 style variations (weight × corner) - Self-hosted webfonts (TTF, WOFF, WOFF2) - Interactive icon explorer with search and filters - FontForge-based build pipeline for generating fonts from SVGs - Drop-in CSS with class-based icon usage Build scripts: - scripts/build-font.py - Standalone FontForge Python script - build-fonts.js - Node.js orchestrator for font generation - update-icon-list.js - Fetch icon metadata from Flaticon API - build-icons-js.js - Generate browser-ready icon dataset Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Co-Authored-By: Z.ai GLM 4.7 <noreply@z.ai>
500 lines
18 KiB
JavaScript
500 lines
18 KiB
JavaScript
const PREFIX_CONFIG = {
|
|
rs: { label: 'Regular Straight', weight: 'regular', corner: 'straight' },
|
|
rr: { label: 'Regular Rounded', weight: 'regular', corner: 'rounded' },
|
|
bs: { label: 'Bold Straight', weight: 'bold', corner: 'straight' },
|
|
br: { label: 'Bold Rounded', weight: 'bold', corner: 'rounded' },
|
|
ss: { label: 'Solid Straight', weight: 'solid', corner: 'straight' },
|
|
sr: { label: 'Solid Rounded', weight: 'solid', corner: 'rounded' },
|
|
ts: { label: 'Thin Straight', weight: 'thin', corner: 'straight' },
|
|
tr: { label: 'Thin Rounded', weight: 'thin', corner: 'rounded' },
|
|
rc: { label: 'Regular Chubby', weight: 'regular', corner: 'chubby' },
|
|
sc: { label: 'Solid Chubby', weight: 'solid', corner: 'chubby' },
|
|
tc: { label: 'Thin Chubby', weight: 'thin', corner: 'chubby' },
|
|
ds: { label: 'Duotone Straight', weight: 'duotone', corner: 'straight' },
|
|
dr: { label: 'Duotone Rounded', weight: 'duotone', corner: 'rounded' },
|
|
dc: { label: 'Duotone Chubby', weight: 'duotone', corner: 'chubby' },
|
|
brands: { label: 'Brands', weight: 'brands', corner: 'brands' }
|
|
};
|
|
|
|
let allIcons = [];
|
|
let iconsByPrefix = {};
|
|
let availablePrefixes = new Set();
|
|
let iconNameToVariants = {};
|
|
|
|
let state = {
|
|
search: '',
|
|
weight: 'regular',
|
|
corner: 'straight',
|
|
type: 'interface',
|
|
page: 1,
|
|
perPage: 120,
|
|
iconSize: 32,
|
|
prefix: null
|
|
};
|
|
|
|
let currentModalIcon = null;
|
|
let currentModalPrefix = null;
|
|
|
|
const searchInput = document.getElementById('searchInput');
|
|
const iconGrid = document.getElementById('iconGrid');
|
|
const emptyState = document.getElementById('emptyState');
|
|
const loadingState = document.getElementById('loadingState');
|
|
const visibleCount = document.getElementById('visibleCount');
|
|
const totalCount = document.getElementById('totalCount');
|
|
const loadingIndicator = document.getElementById('loadingIndicator');
|
|
const toast = document.getElementById('toast');
|
|
const toastMessage = document.getElementById('toastMessage');
|
|
const modalOverlay = document.getElementById('modalOverlay');
|
|
const prevBtn = document.getElementById('prevBtn');
|
|
const nextBtn = document.getElementById('nextBtn');
|
|
const pageInfo = document.getElementById('pageInfo');
|
|
const pageInput = document.getElementById('pageInput');
|
|
const sizeSlider = document.getElementById('sizeSlider');
|
|
const sizeValue = document.getElementById('sizeValue');
|
|
const themeToggle = document.getElementById('themeToggle');
|
|
const modalCopyNameBtn = document.getElementById('modalCopyNameBtn');
|
|
|
|
function getCurrentPrefix() {
|
|
if (state.type === 'brands') return 'brands';
|
|
|
|
const weightMap = {
|
|
regular: { straight: 'rs', rounded: 'rr', chubby: 'rc' },
|
|
bold: { straight: 'bs', rounded: 'br', chubby: null },
|
|
solid: { straight: 'ss', rounded: 'sr', chubby: 'sc' },
|
|
thin: { straight: 'ts', rounded: 'tr', chubby: 'tc' },
|
|
duotone: { straight: 'ds', rounded: 'dr', chubby: 'dc' }
|
|
};
|
|
|
|
return weightMap[state.weight]?.[state.corner] || 'rs';
|
|
}
|
|
|
|
async function loadIcons() {
|
|
try {
|
|
if (Array.isArray(window.FLATICON_ICONS) && window.FLATICON_ICONS.length) {
|
|
allIcons = window.FLATICON_ICONS;
|
|
} else {
|
|
const response = await fetch('data/all_icons.json');
|
|
if (!response.ok) throw new Error('Failed to load icons');
|
|
allIcons = await response.json();
|
|
}
|
|
|
|
iconsByPrefix = {};
|
|
iconNameToVariants = {};
|
|
|
|
allIcons.forEach(icon => {
|
|
const prefix = icon.prefix;
|
|
const name = icon.name;
|
|
|
|
if (!iconsByPrefix[prefix]) iconsByPrefix[prefix] = [];
|
|
iconsByPrefix[prefix].push(icon);
|
|
availablePrefixes.add(prefix);
|
|
|
|
if (!iconNameToVariants[name]) iconNameToVariants[name] = new Set();
|
|
iconNameToVariants[name].add(prefix);
|
|
});
|
|
|
|
loadingState.style.display = 'none';
|
|
updateFilterAvailability();
|
|
renderIcons();
|
|
} catch (error) {
|
|
console.error('Error loading icons:', error);
|
|
loadingState.innerHTML = `
|
|
<div class="empty-icon"><span class="fi fi-rs-exclamation"></span></div>
|
|
<h3>Failed to load icons</h3>
|
|
<p>Run <code>node build-icons-js.js</code> to generate <code>data/all_icons.js</code>, then reload.</p>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function updateFilterAvailability() {
|
|
const weightAvailability = {
|
|
regular: availablePrefixes.has('rs') || availablePrefixes.has('rr') || availablePrefixes.has('rc'),
|
|
bold: availablePrefixes.has('bs') || availablePrefixes.has('br'),
|
|
solid: availablePrefixes.has('ss') || availablePrefixes.has('sr') || availablePrefixes.has('sc'),
|
|
thin: availablePrefixes.has('ts') || availablePrefixes.has('tr') || availablePrefixes.has('tc'),
|
|
duotone: availablePrefixes.has('ds') || availablePrefixes.has('dr') || availablePrefixes.has('dc')
|
|
};
|
|
|
|
document.querySelectorAll('#weightFilter .filter-btn').forEach(btn => {
|
|
const weight = btn.dataset.weight;
|
|
btn.classList.toggle('disabled', !weightAvailability[weight]);
|
|
});
|
|
|
|
updateCornerAvailability();
|
|
}
|
|
|
|
function updateCornerAvailability() {
|
|
const cornerMap = {
|
|
regular: { straight: 'rs', rounded: 'rr', chubby: 'rc' },
|
|
bold: { straight: 'bs', rounded: 'br', chubby: null },
|
|
solid: { straight: 'ss', rounded: 'sr', chubby: 'sc' },
|
|
thin: { straight: 'ts', rounded: 'tr', chubby: 'tc' },
|
|
duotone: { straight: 'ds', rounded: 'dr', chubby: 'dc' }
|
|
};
|
|
|
|
const currentWeight = state.weight;
|
|
|
|
document.querySelectorAll('#cornerFilter .filter-btn').forEach(btn => {
|
|
const corner = btn.dataset.corner;
|
|
const prefix = cornerMap[currentWeight]?.[corner];
|
|
const isAvailable = prefix && availablePrefixes.has(prefix);
|
|
btn.classList.toggle('disabled', !isAvailable);
|
|
});
|
|
}
|
|
|
|
function parseSearchQuery(query) {
|
|
const terms = [];
|
|
const regex = /"([^"]+)"|(\S+)/g;
|
|
let match;
|
|
while ((match = regex.exec(query)) !== null) {
|
|
terms.push((match[1] || match[2]).toLowerCase());
|
|
}
|
|
return terms.filter(term => term.length > 0);
|
|
}
|
|
|
|
function matchesSearch(icon, terms, currentPrefix) {
|
|
if (terms.length === 0) return { matches: true, score: 0 };
|
|
|
|
const name = icon.name.toLowerCase();
|
|
const tags = icon.tags ? icon.tags.toLowerCase().split(',').map(t => t.trim()) : [];
|
|
|
|
let score = 0;
|
|
let allTermsMatch = true;
|
|
|
|
for (const term of terms) {
|
|
let termMatched = false;
|
|
|
|
if (name === term) {
|
|
score += 100;
|
|
termMatched = true;
|
|
}
|
|
else if (name.startsWith(term + '-')) {
|
|
score += 50;
|
|
termMatched = true;
|
|
}
|
|
else if (new RegExp('\\b' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b').test(name)) {
|
|
score += 25;
|
|
termMatched = true;
|
|
}
|
|
else if (name.includes(term)) {
|
|
score += 10;
|
|
termMatched = true;
|
|
}
|
|
else if (tags.some(tag => tag === term)) {
|
|
score += 20;
|
|
termMatched = true;
|
|
}
|
|
else if (tags.some(tag => tag.includes(term))) {
|
|
score += 5;
|
|
termMatched = true;
|
|
}
|
|
|
|
if (!termMatched) {
|
|
allTermsMatch = false;
|
|
}
|
|
}
|
|
|
|
if (icon.prefix === currentPrefix) {
|
|
score += 15;
|
|
}
|
|
|
|
return { matches: allTermsMatch && score > 0, score };
|
|
}
|
|
|
|
function getFilteredIcons() {
|
|
const currentPrefix = getCurrentPrefix();
|
|
let icons = iconsByPrefix[currentPrefix] || [];
|
|
|
|
if (!state.search) return icons;
|
|
|
|
const terms = parseSearchQuery(state.search);
|
|
|
|
const matchingIconNames = new Set();
|
|
|
|
for (const prefix of Object.keys(iconsByPrefix)) {
|
|
for (const icon of iconsByPrefix[prefix]) {
|
|
const result = matchesSearch(icon, terms, currentPrefix);
|
|
if (result.matches) {
|
|
matchingIconNames.add(icon.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
return icons.filter(icon => matchingIconNames.has(icon.name));
|
|
}
|
|
|
|
function animateGrid() {
|
|
const cards = iconGrid.querySelectorAll('.icon-card');
|
|
cards.forEach((card, index) => {
|
|
card.style.animationDelay = `${index * 8}ms`;
|
|
card.classList.add('animate-in');
|
|
});
|
|
}
|
|
|
|
function animateModal() {
|
|
}
|
|
|
|
function renderIcons() {
|
|
const prefix = getCurrentPrefix();
|
|
const filtered = getFilteredIcons();
|
|
const start = (state.page - 1) * state.perPage;
|
|
const pageIcons = filtered.slice(start, start + state.perPage);
|
|
const totalPages = Math.ceil(filtered.length / state.perPage);
|
|
|
|
const totalIconCount = Object.values(iconsByPrefix).reduce((sum, arr) => sum + arr.length, 0);
|
|
totalCount.textContent = totalIconCount.toLocaleString();
|
|
visibleCount.textContent = filtered.length.toLocaleString();
|
|
|
|
document.documentElement.style.setProperty('--icon-size', state.iconSize + 'px');
|
|
document.documentElement.style.setProperty('--icon-card-size', Math.max(100, state.iconSize * 3.2) + 'px');
|
|
|
|
if (pageIcons.length === 0) {
|
|
iconGrid.style.display = 'none';
|
|
emptyState.style.display = 'block';
|
|
document.getElementById('pagination').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
iconGrid.style.display = 'grid';
|
|
emptyState.style.display = 'none';
|
|
document.getElementById('pagination').style.display = 'flex';
|
|
|
|
iconGrid.innerHTML = pageIcons.map(icon => {
|
|
const iconPrefix = icon.prefix || prefix;
|
|
const className = `fi-${iconPrefix}-${icon.name}`;
|
|
return `
|
|
<div class="icon-card" data-name="${icon.name}" data-prefix="${iconPrefix}">
|
|
<span class="fi ${className} icon"></span>
|
|
<div class="name">${icon.name}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
animateGrid();
|
|
|
|
prevBtn.disabled = state.page === 1;
|
|
nextBtn.disabled = state.page >= totalPages;
|
|
pageInfo.textContent = `Page ${state.page} of ${Math.max(1, totalPages)}`;
|
|
pageInput.max = totalPages;
|
|
pageInput.placeholder = state.page;
|
|
}
|
|
|
|
function showToast(msg) {
|
|
toastMessage.textContent = msg;
|
|
toast.classList.add('show');
|
|
setTimeout(() => toast.classList.remove('show'), 2500);
|
|
}
|
|
|
|
function copyToClipboard(text) {
|
|
navigator.clipboard.writeText(text).then(() => showToast('Copied: ' + text));
|
|
}
|
|
|
|
function showModal(name, currentPrefix) {
|
|
currentModalIcon = name;
|
|
currentModalPrefix = currentPrefix;
|
|
|
|
const modalIcon = document.getElementById('modalIcon');
|
|
const modalTitle = document.getElementById('modalTitle');
|
|
const modalSubtitle = document.getElementById('modalSubtitle');
|
|
const modalVariations = document.getElementById('modalVariations');
|
|
|
|
modalTitle.textContent = name;
|
|
|
|
const availableVariants = iconNameToVariants[name] || new Set();
|
|
modalSubtitle.textContent = `Available in ${availableVariants.size} variation${availableVariants.size !== 1 ? 's' : ''}`;
|
|
|
|
const className = `fi-${currentPrefix}-${name}`;
|
|
modalIcon.className = `modal-icon fi ${className}`;
|
|
|
|
const allPrefixes = Object.keys(PREFIX_CONFIG);
|
|
modalVariations.innerHTML = allPrefixes.map(prefix => {
|
|
const config = PREFIX_CONFIG[prefix];
|
|
const isAvailable = availableVariants.has(prefix);
|
|
const varClassName = `fi-${prefix}-${name}`;
|
|
const iconMarkup = isAvailable
|
|
? `<span class="fi ${varClassName} var-icon"></span>`
|
|
: `<span class="var-icon">N/A</span>`;
|
|
return `
|
|
<div class="modal-var ${prefix === currentPrefix ? 'active' : ''} ${!isAvailable ? 'unavailable' : ''}"
|
|
data-prefix="${prefix}" data-name="${name}">
|
|
${iconMarkup}
|
|
<div class="var-label">${config.label.split(' ').map(w => w[0]).join('')}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
modalVariations.querySelectorAll('.modal-var:not(.unavailable)').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const prefix = el.dataset.prefix;
|
|
const newClassName = `fi-${prefix}-${name}`;
|
|
modalIcon.className = `modal-icon fi ${newClassName}`;
|
|
currentModalPrefix = prefix;
|
|
modalVariations.querySelectorAll('.modal-var').forEach(v => v.classList.remove('active'));
|
|
el.classList.add('active');
|
|
});
|
|
});
|
|
|
|
modalOverlay.classList.add('show');
|
|
animateModal();
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const isDark = document.documentElement.dataset.theme === 'dark';
|
|
document.documentElement.dataset.theme = isDark ? 'light' : 'dark';
|
|
themeToggle.innerHTML = isDark
|
|
? '<span class="fi fi-rs-sun"></span>'
|
|
: '<span class="fi fi-rs-moon"></span>';
|
|
localStorage.setItem('theme', isDark ? 'light' : 'dark');
|
|
}
|
|
|
|
function initTheme() {
|
|
const currentTheme = document.documentElement.dataset.theme || 'dark';
|
|
themeToggle.innerHTML = currentTheme === 'dark'
|
|
? '<span class="fi fi-rs-moon"></span>'
|
|
: '<span class="fi fi-rs-sun"></span>';
|
|
}
|
|
|
|
searchInput.addEventListener('input', e => {
|
|
state.search = e.target.value;
|
|
state.page = 1;
|
|
renderIcons();
|
|
});
|
|
|
|
document.getElementById('weightFilter').addEventListener('click', e => {
|
|
const btn = e.target.closest('.filter-btn');
|
|
if (!btn || btn.classList.contains('disabled')) return;
|
|
|
|
document.querySelectorAll('#weightFilter .filter-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
state.weight = btn.dataset.weight;
|
|
state.page = 1;
|
|
|
|
updateCornerAvailability();
|
|
const currentCornerBtn = document.querySelector('#cornerFilter .filter-btn.active');
|
|
if (currentCornerBtn?.classList.contains('disabled')) {
|
|
const validCorner = document.querySelector('#cornerFilter .filter-btn:not(.disabled)');
|
|
if (validCorner) {
|
|
document.querySelectorAll('#cornerFilter .filter-btn').forEach(b => b.classList.remove('active'));
|
|
validCorner.classList.add('active');
|
|
state.corner = validCorner.dataset.corner;
|
|
}
|
|
}
|
|
|
|
renderIcons();
|
|
});
|
|
|
|
document.getElementById('cornerFilter').addEventListener('click', e => {
|
|
const btn = e.target.closest('.filter-btn');
|
|
if (!btn || btn.classList.contains('disabled')) return;
|
|
|
|
document.querySelectorAll('#cornerFilter .filter-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
state.corner = btn.dataset.corner;
|
|
state.page = 1;
|
|
renderIcons();
|
|
});
|
|
|
|
document.getElementById('typeFilter').addEventListener('click', e => {
|
|
const btn = e.target.closest('.filter-btn');
|
|
if (!btn) return;
|
|
|
|
document.querySelectorAll('#typeFilter .filter-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
state.type = btn.dataset.type;
|
|
state.page = 1;
|
|
|
|
const isBrands = state.type === 'brands';
|
|
document.getElementById('weightFilter').parentElement.classList.toggle('disabled-section', isBrands);
|
|
document.getElementById('cornerFilter').parentElement.classList.toggle('disabled-section', isBrands);
|
|
document.querySelectorAll('#weightFilter .filter-btn, #cornerFilter .filter-btn').forEach(b => {
|
|
b.classList.toggle('disabled', isBrands);
|
|
});
|
|
|
|
renderIcons();
|
|
});
|
|
|
|
sizeSlider.addEventListener('input', e => {
|
|
state.iconSize = parseInt(e.target.value);
|
|
sizeValue.textContent = state.iconSize + 'px';
|
|
renderIcons();
|
|
});
|
|
|
|
iconGrid.addEventListener('click', e => {
|
|
const card = e.target.closest('.icon-card');
|
|
if (card) {
|
|
showModal(card.dataset.name, card.dataset.prefix);
|
|
}
|
|
});
|
|
|
|
document.getElementById('modalCopyBtn').addEventListener('click', () => {
|
|
const className = `fi-${currentModalPrefix}-${currentModalIcon}`;
|
|
copyToClipboard(`<span class="fi ${className}"></span>`);
|
|
});
|
|
|
|
document.getElementById('modalCopyCssBtn').addEventListener('click', () => {
|
|
const className = `fi-${currentModalPrefix}-${currentModalIcon}`;
|
|
copyToClipboard(`fi ${className}`);
|
|
});
|
|
|
|
modalCopyNameBtn.addEventListener('click', () => {
|
|
copyToClipboard(currentModalIcon);
|
|
});
|
|
|
|
document.getElementById('modalClose').addEventListener('click', () => {
|
|
modalOverlay.classList.remove('show');
|
|
});
|
|
|
|
modalOverlay.addEventListener('click', e => {
|
|
if (e.target === modalOverlay) modalOverlay.classList.remove('show');
|
|
});
|
|
|
|
prevBtn.addEventListener('click', () => {
|
|
if (state.page > 1) {
|
|
state.page--;
|
|
renderIcons();
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
});
|
|
|
|
nextBtn.addEventListener('click', () => {
|
|
const totalPages = Math.ceil(getFilteredIcons().length / state.perPage);
|
|
if (state.page < totalPages) {
|
|
state.page++;
|
|
renderIcons();
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
});
|
|
|
|
document.getElementById('goBtn').addEventListener('click', () => {
|
|
const page = parseInt(pageInput.value);
|
|
const totalPages = Math.ceil(getFilteredIcons().length / state.perPage);
|
|
if (page >= 1 && page <= totalPages) {
|
|
state.page = page;
|
|
renderIcons();
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
});
|
|
|
|
pageInput.addEventListener('keypress', e => {
|
|
if (e.key === 'Enter') document.getElementById('goBtn').click();
|
|
});
|
|
|
|
themeToggle.addEventListener('click', toggleTheme);
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') modalOverlay.classList.remove('show');
|
|
if (e.key === '/' && document.activeElement !== searchInput) {
|
|
e.preventDefault();
|
|
searchInput.focus();
|
|
}
|
|
if (e.key === 'ArrowLeft' && !modalOverlay.classList.contains('show')) {
|
|
prevBtn.click();
|
|
}
|
|
if (e.key === 'ArrowRight' && !modalOverlay.classList.contains('show')) {
|
|
nextBtn.click();
|
|
}
|
|
});
|
|
|
|
initTheme();
|
|
loadIcons();
|