Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
484 lines
20 KiB
JavaScript
484 lines
20 KiB
JavaScript
const iconPicker = {
|
|
currentPack: 'all',
|
|
searchQuery: '',
|
|
selectedIcon: null,
|
|
onSelect: null,
|
|
loadedPacks: {},
|
|
currentLetter: '',
|
|
currentPage: 1,
|
|
itemsPerPage: 200,
|
|
|
|
packs: {
|
|
lucide: { name: 'Lucide', color: '#F56565', prefix: 'lucide' },
|
|
tabler: { name: 'Tabler', color: '#4299E1', prefix: 'tabler' },
|
|
'material-symbols': { name: 'Material', color: '#48BB78', prefix: 'material-symbols' },
|
|
mdi: { name: 'MDI', color: '#A855F7', prefix: 'mdi' },
|
|
'simple-icons': { name: 'Simple Icons', color: '#10B981', prefix: 'simple-icons' },
|
|
business: { name: 'Business', color: '#F59E0B', prefix: null }
|
|
},
|
|
|
|
icons: {
|
|
lucide: [],
|
|
tabler: [],
|
|
'material-symbols': [],
|
|
mdi: [],
|
|
'simple-icons': [],
|
|
// Business icons use explicit icon keys across multiple collections.
|
|
// This avoids silent failures when an app is not available in one collection.
|
|
business: [
|
|
'simple-icons:mysql',
|
|
'simple-icons:mariadb',
|
|
'simple-icons:postgresql',
|
|
'simple-icons:mongodb',
|
|
'simple-icons:redis',
|
|
'simple-icons:sqlite',
|
|
|
|
'simple-icons:docker',
|
|
'simple-icons:kubernetes',
|
|
'simple-icons:portainer',
|
|
'simple-icons:traefikproxy',
|
|
'simple-icons:nginx',
|
|
'simple-icons:apache',
|
|
|
|
'simple-icons:plex',
|
|
'simple-icons:jellyfin',
|
|
'simple-icons:emby',
|
|
'cbi:sonarr',
|
|
'cbi:radarr',
|
|
'cbi:prowlarr',
|
|
|
|
'simple-icons:nextcloud',
|
|
'simple-icons:owncloud',
|
|
'simple-icons:seafile',
|
|
'simple-icons:syncthing',
|
|
|
|
'simple-icons:grafana',
|
|
'simple-icons:prometheus',
|
|
'simple-icons:influxdb',
|
|
'simple-icons:elasticsearch',
|
|
'simple-icons:kibana',
|
|
|
|
'simple-icons:homeassistant',
|
|
'simple-icons:nodered',
|
|
'simple-icons:eclipsemosquitto',
|
|
'simple-icons:zigbee2mqtt',
|
|
|
|
'simple-icons:pihole',
|
|
'simple-icons:adguard',
|
|
'simple-icons:wireguard',
|
|
'simple-icons:openvpn',
|
|
|
|
'simple-icons:gitlab',
|
|
'simple-icons:gitea',
|
|
'simple-icons:github',
|
|
'simple-icons:bitbucket',
|
|
|
|
'simple-icons:jenkins',
|
|
'simple-icons:ansible',
|
|
'simple-icons:terraform',
|
|
'simple-icons:vault',
|
|
|
|
'simple-icons:wordpress',
|
|
'simple-icons:ghost',
|
|
'simple-icons:drupal',
|
|
'simple-icons:joomla',
|
|
|
|
'simple-icons:phpmyadmin',
|
|
'simple-icons:adminer',
|
|
|
|
'simple-icons:bitwarden',
|
|
'simple-icons:vaultwarden',
|
|
'simple-icons:keycloak',
|
|
'simple-icons:authelia',
|
|
|
|
'simple-icons:transmission',
|
|
'simple-icons:qbittorrent',
|
|
'simple-icons:deluge',
|
|
|
|
'simple-icons:frigate',
|
|
'simple-icons:immich',
|
|
'simple-icons:piwigo',
|
|
|
|
'simple-icons:calibreweb',
|
|
'simple-icons:truenas',
|
|
'simple-icons:openmediavault',
|
|
'simple-icons:cockpit',
|
|
'simple-icons:webmin',
|
|
|
|
'cbi:proxmox',
|
|
'simple-icons:vmware',
|
|
'simple-icons:unraid',
|
|
'simple-icons:opnsense',
|
|
'simple-icons:pfsense'
|
|
]
|
|
},
|
|
|
|
show(onSelectCallback, currentIconKey = null) {
|
|
this.onSelect = onSelectCallback;
|
|
this.selectedIcon = currentIconKey;
|
|
this.searchQuery = '';
|
|
this.currentPack = 'all';
|
|
|
|
const modal = document.getElementById('icon-picker-modal');
|
|
if (!modal) {
|
|
this.createModal();
|
|
}
|
|
|
|
this.render();
|
|
document.getElementById('icon-picker-modal').classList.remove('hidden');
|
|
document.getElementById('icon-picker-search').focus();
|
|
},
|
|
|
|
close() {
|
|
document.getElementById('icon-picker-modal').classList.add('hidden');
|
|
},
|
|
|
|
async ensurePackLoaded(pack) {
|
|
if (pack === 'business' || pack === 'all') return;
|
|
if (this.loadedPacks[pack]) return;
|
|
|
|
const packConfig = this.packs[pack];
|
|
if (!packConfig || !packConfig.prefix) return;
|
|
|
|
this.loadedPacks[pack] = 'loading';
|
|
|
|
try {
|
|
const url = `https://api.iconify.design/collection?prefix=${packConfig.prefix}`;
|
|
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
|
if (!res.ok) throw new Error(`Iconify API error: ${res.status}`);
|
|
|
|
const data = await res.json();
|
|
const set = new Set();
|
|
|
|
if (Array.isArray(data.uncategorized)) {
|
|
data.uncategorized.forEach((name) => {
|
|
if (typeof name === 'string' && name) set.add(name);
|
|
});
|
|
}
|
|
if (data && typeof data.categories === 'object' && data.categories) {
|
|
Object.values(data.categories).forEach((arr) => {
|
|
if (!Array.isArray(arr)) return;
|
|
arr.forEach((name) => {
|
|
if (typeof name === 'string' && name) set.add(name);
|
|
});
|
|
});
|
|
}
|
|
|
|
this.icons[pack] = Array.from(set).sort();
|
|
this.loadedPacks[pack] = true;
|
|
} catch (e) {
|
|
console.warn(`Failed to load ${pack} icons from Iconify API:`, e);
|
|
this.loadedPacks[pack] = false;
|
|
}
|
|
},
|
|
|
|
createModal() {
|
|
const modalHtml = `
|
|
<div id="icon-picker-modal" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] hidden flex items-center justify-center p-4">
|
|
<div class="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-6xl max-h-[90vh] flex flex-col">
|
|
<div class="flex items-center justify-between p-4 border-b border-gray-700">
|
|
<h3 class="text-lg font-semibold">Choisir une icône</h3>
|
|
<button onclick="iconPicker.close()" class="p-2 hover:bg-gray-800 rounded-lg transition-colors">
|
|
<i class="fas fa-times text-gray-400"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="p-4 border-b border-gray-700 space-y-3">
|
|
<input
|
|
id="icon-picker-search"
|
|
type="text"
|
|
placeholder="Rechercher une icône..."
|
|
oninput="iconPicker.handleSearch(event)"
|
|
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500"
|
|
/>
|
|
|
|
<div class="flex flex-wrap gap-2" id="icon-picker-filters">
|
|
<button onclick="iconPicker.setFilter('all')" data-filter="all" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-purple-600 text-white">
|
|
Tous
|
|
</button>
|
|
<button onclick="iconPicker.setFilter('lucide')" data-filter="lucide" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
|
|
Lucide
|
|
</button>
|
|
<button onclick="iconPicker.setFilter('tabler')" data-filter="tabler" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
|
|
Tabler
|
|
</button>
|
|
<button onclick="iconPicker.setFilter('material-symbols')" data-filter="material-symbols" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
|
|
Material
|
|
</button>
|
|
<button onclick="iconPicker.setFilter('mdi')" data-filter="mdi" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
|
|
MDI
|
|
</button>
|
|
<button onclick="iconPicker.setFilter('simple-icons')" data-filter="simple-icons" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
|
|
Simple Icons
|
|
</button>
|
|
<button onclick="iconPicker.setFilter('business')" data-filter="business" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
|
|
Business
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-1 mt-2" id="icon-picker-alphabet">
|
|
<button onclick="iconPicker.setLetter('')" data-letter="" class="px-2 py-1 rounded text-xs font-medium transition-colors bg-purple-600 text-white">
|
|
#
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="icon-picker-grid" class="flex-1 overflow-y-auto p-4">
|
|
<div class="grid grid-cols-10 sm:grid-cols-12 md:grid-cols-14 lg:grid-cols-16 xl:grid-cols-18 gap-2"></div>
|
|
</div>
|
|
|
|
<div class="border-t border-gray-700 p-3">
|
|
<div id="icon-picker-pagination" class="flex items-center justify-center gap-2"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
|
|
this.renderAlphabetFilter();
|
|
|
|
document.getElementById('icon-picker-modal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'icon-picker-modal') {
|
|
this.close();
|
|
}
|
|
});
|
|
},
|
|
|
|
renderAlphabetFilter() {
|
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
|
const container = document.getElementById('icon-picker-alphabet');
|
|
if (!container) return;
|
|
|
|
const buttons = alphabet.map(letter => `
|
|
<button onclick="iconPicker.setLetter('${letter}')" data-letter="${letter}" class="px-2 py-1 rounded text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
|
|
${letter}
|
|
</button>
|
|
`).join('');
|
|
|
|
container.innerHTML = `
|
|
<button onclick="iconPicker.setLetter('')" data-letter="" class="px-2 py-1 rounded text-xs font-medium transition-colors bg-purple-600 text-white">
|
|
#
|
|
</button>
|
|
${buttons}
|
|
`;
|
|
},
|
|
|
|
handleSearch(event) {
|
|
this.searchQuery = event.target.value.toLowerCase();
|
|
this.render();
|
|
},
|
|
|
|
setFilter(pack) {
|
|
this.currentPack = pack;
|
|
this.currentPage = 1;
|
|
|
|
document.querySelectorAll('#icon-picker-filters button').forEach(btn => {
|
|
if (btn.dataset.filter === pack) {
|
|
btn.className = 'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-purple-600 text-white';
|
|
} else {
|
|
btn.className = 'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600';
|
|
}
|
|
});
|
|
|
|
this.render();
|
|
},
|
|
|
|
setLetter(letter) {
|
|
this.currentLetter = letter.toLowerCase();
|
|
this.currentPage = 1;
|
|
|
|
document.querySelectorAll('#icon-picker-alphabet button').forEach(btn => {
|
|
if (btn.dataset.letter === letter) {
|
|
btn.className = 'px-2 py-1 rounded text-xs font-medium transition-colors bg-purple-600 text-white';
|
|
} else {
|
|
btn.className = 'px-2 py-1 rounded text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600';
|
|
}
|
|
});
|
|
|
|
this.render();
|
|
},
|
|
|
|
setPage(page) {
|
|
this.currentPage = page;
|
|
this.render();
|
|
},
|
|
|
|
getFilteredIcons() {
|
|
const result = [];
|
|
const packs = this.currentPack === 'all' ? Object.keys(this.icons) : [this.currentPack];
|
|
|
|
packs.forEach(pack => {
|
|
const icons = this.icons[pack] || [];
|
|
icons.forEach(icon => {
|
|
const isFullKey = typeof icon === 'string' && icon.includes(':');
|
|
const iconKey = isFullKey ? icon : `${pack}:${icon}`;
|
|
const iconName = isFullKey ? icon.split(':').slice(1).join(':') : icon;
|
|
const q = this.searchQuery;
|
|
const letter = this.currentLetter;
|
|
|
|
const matchesSearch = !q || iconName.includes(q) || iconKey.includes(q);
|
|
const matchesLetter = !letter || iconName.toLowerCase().startsWith(letter);
|
|
|
|
if (matchesSearch && matchesLetter) {
|
|
result.push({ pack, icon: iconName, key: iconKey });
|
|
}
|
|
});
|
|
});
|
|
|
|
return result;
|
|
},
|
|
|
|
getPaginatedIcons(allIcons) {
|
|
const start = (this.currentPage - 1) * this.itemsPerPage;
|
|
const end = start + this.itemsPerPage;
|
|
return {
|
|
icons: allIcons.slice(start, end),
|
|
total: allIcons.length,
|
|
totalPages: Math.ceil(allIcons.length / this.itemsPerPage)
|
|
};
|
|
},
|
|
|
|
renderPagination(totalPages, total) {
|
|
const container = document.getElementById('icon-picker-pagination');
|
|
if (!container || totalPages <= 1) {
|
|
if (container) container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const maxButtons = 7;
|
|
let startPage = Math.max(1, this.currentPage - Math.floor(maxButtons / 2));
|
|
let endPage = Math.min(totalPages, startPage + maxButtons - 1);
|
|
|
|
if (endPage - startPage < maxButtons - 1) {
|
|
startPage = Math.max(1, endPage - maxButtons + 1);
|
|
}
|
|
|
|
let html = `
|
|
<button onclick="iconPicker.setPage(${this.currentPage - 1})"
|
|
class="px-3 py-1 rounded text-sm transition-colors ${this.currentPage === 1 ? 'bg-gray-800 text-gray-600 cursor-not-allowed' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
${this.currentPage === 1 ? 'disabled' : ''}>
|
|
←
|
|
</button>
|
|
`;
|
|
|
|
if (startPage > 1) {
|
|
html += `
|
|
<button onclick="iconPicker.setPage(1)" class="px-3 py-1 rounded text-sm transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
|
|
1
|
|
</button>
|
|
`;
|
|
if (startPage > 2) {
|
|
html += '<span class="text-gray-500 px-2">...</span>';
|
|
}
|
|
}
|
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
html += `
|
|
<button onclick="iconPicker.setPage(${i})"
|
|
class="px-3 py-1 rounded text-sm transition-colors ${i === this.currentPage ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}">
|
|
${i}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
if (endPage < totalPages) {
|
|
if (endPage < totalPages - 1) {
|
|
html += '<span class="text-gray-500 px-2">...</span>';
|
|
}
|
|
html += `
|
|
<button onclick="iconPicker.setPage(${totalPages})" class="px-3 py-1 rounded text-sm transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
|
|
${totalPages}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
html += `
|
|
<button onclick="iconPicker.setPage(${this.currentPage + 1})"
|
|
class="px-3 py-1 rounded text-sm transition-colors ${this.currentPage === totalPages ? 'bg-gray-800 text-gray-600 cursor-not-allowed' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
${this.currentPage === totalPages ? 'disabled' : ''}>
|
|
→
|
|
</button>
|
|
<span class="text-gray-400 text-sm ml-3">
|
|
${total} icônes
|
|
</span>
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
},
|
|
|
|
async render() {
|
|
const packsToLoad = this.currentPack === 'all'
|
|
? Object.keys(this.packs).filter(p => p !== 'business')
|
|
: [this.currentPack];
|
|
|
|
await Promise.all(packsToLoad.map(pack => this.ensurePackLoaded(pack)));
|
|
|
|
const grid = document.querySelector('#icon-picker-grid .grid');
|
|
const allIcons = this.getFilteredIcons();
|
|
const { icons, total, totalPages } = this.getPaginatedIcons(allIcons);
|
|
|
|
if (icons.length === 0) {
|
|
grid.innerHTML = `
|
|
<div class="col-span-8 text-center py-8 text-gray-500">
|
|
<i class="fas fa-search text-2xl mb-2"></i>
|
|
<p class="text-sm">Aucune icône trouvée</p>
|
|
</div>
|
|
`;
|
|
this.renderPagination(0, 0);
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = icons.map(({ pack, icon, key }) => {
|
|
const isSelected = this.selectedIcon === key;
|
|
const displayName = icon;
|
|
const forcedColor = (this.currentPack === 'business' || pack === 'business') ? '#e5e7eb' : 'inherit';
|
|
return `
|
|
<button
|
|
onclick="iconPicker.selectIcon('${key}')"
|
|
class="aspect-square p-2 rounded-lg border transition-all hover:scale-110 hover:shadow-lg flex flex-col items-center justify-center gap-1 ${
|
|
isSelected
|
|
? 'bg-purple-600/20 border-purple-500'
|
|
: 'bg-gray-800 border-gray-700 hover:border-gray-500'
|
|
}"
|
|
title="${key}"
|
|
>
|
|
<span class="iconify text-2xl" data-icon="${key}" style="color:${forcedColor}"></span>
|
|
<span class="text-[10px] leading-tight text-gray-400 max-w-full truncate w-full text-center">${displayName}</span>
|
|
</button>
|
|
`;
|
|
}).join('');
|
|
|
|
// Handle missing icons (e.g., wrong key): Iconify will keep them blank.
|
|
// We proactively load icons and replace missing ones with a fallback.
|
|
if (window.Iconify && typeof window.Iconify.loadIcons === 'function') {
|
|
const keys = icons.map(i => i.key);
|
|
window.Iconify.loadIcons(keys, (loaded, missing, pending) => {
|
|
if (!Array.isArray(missing) || missing.length === 0) return;
|
|
missing.forEach((k) => {
|
|
const escaped = (window.CSS && typeof window.CSS.escape === 'function')
|
|
? window.CSS.escape(k)
|
|
: String(k).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
const el = document.querySelector(`#icon-picker-grid .iconify[data-icon="${escaped}"]`);
|
|
if (el) {
|
|
el.setAttribute('data-icon', 'lucide:help-circle');
|
|
el.classList.add('text-gray-500');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
this.renderPagination(totalPages, total);
|
|
},
|
|
|
|
selectIcon(iconKey) {
|
|
this.selectedIcon = iconKey;
|
|
if (this.onSelect) {
|
|
this.onSelect(iconKey);
|
|
}
|
|
this.close();
|
|
}
|
|
};
|
|
|
|
window.iconPicker = iconPicker;
|