homelab_automation/app/icon_picker.js
Bruno Charest 661d005fc7
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
Add favorites feature with toggle filters, group management, color/icon pickers, dashboard widget, and star buttons across containers/docker sections with persistent storage and real-time UI updates
2025-12-23 14:56:31 -05:00

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;