|
180 | 180 | text-decoration: none; |
181 | 181 | color: inherit; |
182 | 182 | transition: transform 0.2s ease, box-shadow 0.2s ease; |
| 183 | + position: relative; |
| 184 | + overflow: hidden; |
183 | 185 | } |
| 186 | + .route-icon-img { |
| 187 | + width: 100%; |
| 188 | + height: 100%; |
| 189 | + object-fit: contain; |
| 190 | + display: none; |
| 191 | + } |
| 192 | + .route-icon-img.is-visible { display: block; } |
| 193 | + .route-icon-fallback { position: absolute; inset: 0; display:flex; align-items:center; justify-content:center; } |
184 | 194 |
|
185 | 195 | .route-card--link:hover { |
186 | 196 | transform: translateY(-3px); |
|
398 | 408 | // Configuration |
399 | 409 | let config = { |
400 | 410 | refresh_interval: 5, |
401 | | - default_theme: 'dynamic' |
| 411 | + default_theme: 'dynamic', |
| 412 | + icon_dynamic: { enabled: false, base_url: '', extension: '.svg' } |
402 | 413 | }; |
403 | 414 |
|
404 | 415 | // State |
@@ -564,21 +575,20 @@ <h2>No Routes Found</h2> |
564 | 575 | `; |
565 | 576 |
|
566 | 577 | container.innerHTML = html; |
567 | | - setupIconFallbacks(); |
| 578 | + initializeIcons(); |
568 | 579 | } |
569 | 580 |
|
570 | 581 | // Create route card HTML |
571 | 582 | function createRouteCard(route) { |
572 | 583 | const icon = iconMap[route.icon] || iconMap['mdi:web']; |
573 | | - const fallbackIcon = escapeHtml(icon); |
574 | | - const iconUrl = route.iconUrl ? escapeAttribute(route.iconUrl) : ''; |
575 | | - const iconAlt = escapeAttribute(`${route.name} icon`); |
| 584 | + const candidates = buildIconCandidates(route); |
| 585 | + const dataset = candidates.map(escapeAttribute).join('|'); |
576 | 586 | const statusDotClass = route.status === 'enabled' ? '' : (route.status === 'disabled' ? ' error' : ' warning'); |
577 | 587 |
|
578 | 588 | const iconMarkup = ` |
579 | | - <div class="route-icon"> |
580 | | - ${iconUrl ? `<img src="${iconUrl}" alt="${iconAlt}" class="route-icon-img" loading="lazy" decoding="async" referrerpolicy="no-referrer" />` : ''} |
581 | | - <div class="route-icon-fallback">${fallbackIcon}</div> |
| 589 | + <div class="route-icon" data-fallback-emoji="${escapeAttribute(icon)}"> |
| 590 | + <img class="route-icon-img" data-icon-candidates="${dataset}" alt="${escapeAttribute(route.name || 'icon')}"> |
| 591 | + <div class="route-icon-fallback">${escapeHtml(icon)}</div> |
582 | 592 | </div> |
583 | 593 | `; |
584 | 594 |
|
@@ -683,26 +693,70 @@ <h2>Error Loading Routes</h2> |
683 | 693 | .replace(/>/g, '>'); |
684 | 694 | } |
685 | 695 |
|
686 | | - function setupIconFallbacks() { |
687 | | - document.querySelectorAll('.route-icon-img').forEach(img => { |
688 | | - const evaluateState = () => { |
689 | | - if (img.naturalWidth === 0 || img.naturalHeight === 0) { |
690 | | - img.remove(); |
691 | | - } else { |
692 | | - img.classList.add('is-visible'); |
693 | | - } |
694 | | - }; |
| 696 | + function buildIconCandidates(route) { |
| 697 | + const candidates = []; |
| 698 | + const dyn = config.icon_dynamic || {}; |
| 699 | + const base = dyn.base_url || ''; |
| 700 | + const ext = dyn.extension || '.svg'; |
| 701 | + |
| 702 | + const names = []; |
| 703 | + const svc = (route.service || '').toString(); |
| 704 | + const name = (route.name || '').toString(); |
| 705 | + const cleaned = s => s |
| 706 | + .replace(/@.*$/, '') |
| 707 | + .replace(/-(http|https)$/i, '') |
| 708 | + .replace(/[^a-z0-9]+/gi, '-') |
| 709 | + .replace(/^-+|-+$/g, '') |
| 710 | + .toLowerCase(); |
| 711 | + |
| 712 | + if (svc) names.push(cleaned(svc)); |
| 713 | + if (name) names.push(cleaned(name)); |
| 714 | + |
| 715 | + const uniq = Array.from(new Set(names)); |
| 716 | + |
| 717 | + if (dyn.enabled && base) { |
| 718 | + uniq.forEach(slug => { |
| 719 | + candidates.push(`${base}${slug}${ext}`); |
| 720 | + candidates.push(`${base}${slug}.png`); |
| 721 | + candidates.push(`${base}${slug}.webp`); |
| 722 | + }); |
| 723 | + } |
695 | 724 |
|
696 | | - if (img.complete) { |
697 | | - evaluateState(); |
698 | | - } else { |
699 | | - img.addEventListener('load', () => { |
700 | | - img.classList.add('is-visible'); |
701 | | - }, { once: true }); |
702 | | - img.addEventListener('error', () => { |
703 | | - img.remove(); |
704 | | - }, { once: true }); |
| 725 | + if (route.url) { |
| 726 | + try { |
| 727 | + const u = new URL(route.url); |
| 728 | + candidates.push(`https://icon.horse/icon/${u.hostname}`); |
| 729 | + candidates.push(`${u.origin}/favicon.ico`); |
| 730 | + } catch {} |
| 731 | + } |
| 732 | + |
| 733 | + return candidates; |
| 734 | + } |
| 735 | + |
| 736 | + function initializeIcons() { |
| 737 | + document.querySelectorAll('img[data-icon-candidates]').forEach(img => { |
| 738 | + const list = (img.getAttribute('data-icon-candidates') || '').split('|').filter(Boolean); |
| 739 | + if (list.length === 0) { |
| 740 | + return; // fallback emoji remains |
705 | 741 | } |
| 742 | + let i = 0; |
| 743 | + const tryNext = () => { |
| 744 | + if (i >= list.length) { |
| 745 | + img.removeAttribute('src'); |
| 746 | + return; |
| 747 | + } |
| 748 | + img.src = list[i++]; |
| 749 | + }; |
| 750 | + img.addEventListener('error', tryNext); |
| 751 | + img.addEventListener('load', () => { |
| 752 | + img.classList.add('is-visible'); |
| 753 | + const parent = img.closest('.route-icon'); |
| 754 | + if (parent) { |
| 755 | + const fallback = parent.querySelector('.route-icon-fallback'); |
| 756 | + if (fallback) fallback.style.display = 'none'; |
| 757 | + } |
| 758 | + }); |
| 759 | + tryNext(); |
706 | 760 | }); |
707 | 761 | } |
708 | 762 |
|
|
0 commit comments