Skip to content

Commit a13fb5a

Browse files
committed
feat(icons): dynamic icons via CDN with favicon fallbacks; expose config in /api/config
1 parent 9d39531 commit a13fb5a

2 files changed

Lines changed: 87 additions & 27 deletions

File tree

app.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,10 +461,16 @@ def index():
461461
@app.route('/api/config')
462462
def get_config():
463463
"""Return dashboard configuration"""
464+
icon_dynamic = config.get('icons', {}).get('dynamic', {})
464465
return jsonify({
465466
'title': config['dashboard']['title'],
466467
'default_theme': config['dashboard']['default_theme'],
467-
'refresh_interval': config['traefik']['refresh_interval']
468+
'refresh_interval': config['traefik']['refresh_interval'],
469+
'icon_dynamic': {
470+
'enabled': bool(icon_dynamic.get('enabled', False)),
471+
'base_url': icon_dynamic.get('base_url', ''),
472+
'extension': icon_dynamic.get('extension', '.svg')
473+
}
468474
})
469475

470476
@app.route('/api/routes')

static/index.html

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,17 @@
180180
text-decoration: none;
181181
color: inherit;
182182
transition: transform 0.2s ease, box-shadow 0.2s ease;
183+
position: relative;
184+
overflow: hidden;
183185
}
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; }
184194

185195
.route-card--link:hover {
186196
transform: translateY(-3px);
@@ -398,7 +408,8 @@ <h1>
398408
// Configuration
399409
let config = {
400410
refresh_interval: 5,
401-
default_theme: 'dynamic'
411+
default_theme: 'dynamic',
412+
icon_dynamic: { enabled: false, base_url: '', extension: '.svg' }
402413
};
403414

404415
// State
@@ -564,21 +575,20 @@ <h2>No Routes Found</h2>
564575
`;
565576

566577
container.innerHTML = html;
567-
setupIconFallbacks();
578+
initializeIcons();
568579
}
569580

570581
// Create route card HTML
571582
function createRouteCard(route) {
572583
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('|');
576586
const statusDotClass = route.status === 'enabled' ? '' : (route.status === 'disabled' ? ' error' : ' warning');
577587

578588
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>
582592
</div>
583593
`;
584594

@@ -683,26 +693,70 @@ <h2>Error Loading Routes</h2>
683693
.replace(/>/g, '&gt;');
684694
}
685695

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+
}
695724

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
705741
}
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();
706760
});
707761
}
708762

0 commit comments

Comments
 (0)