microdash/app.js
2025-08-24 09:39:05 -04:00

373 lines
14 KiB
JavaScript

// app.js — config-driven dash with strong icon fallback, basePath,
// robust grid spanning, and full set of knobs
const elApp = document.getElementById('app');
const elSearch = document.getElementById('search');
let DEFAULT_ICON_SCALE = 1;
let ICON_BOX_PCT = '85%';
let FALLBACK_ICON = '';
let ICON_BASE = ''; // NEW: optional basePath for icons
let BUILTIN_FALLBACK = // SVG data-URI fallback if everything fails
'data:image/svg+xml;utf8,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="8" fill="#2a2a2a"/>
<path d="M10 46l10-12 7 8 9-12 18 24H10z" fill="#bdbdbd"/>
<circle cx="24" cy="22" r="4" fill="#e0e0e0"/>
</svg>
`);
/* Focus search on "/" */
window.addEventListener('keydown', (e) => {
if (e.key === '/' && document.activeElement !== elSearch) {
e.preventDefault();
elSearch?.focus();
elSearch?.select();
}
});
/* Load config */
fetch('config.json', { cache: 'no-store' })
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(cfg => {
// knobs
DEFAULT_ICON_SCALE = cfg?.icons?.defaultScale ?? 1;
ICON_BOX_PCT = cfg?.icons?.boxPercent ?? '85%';
FALLBACK_ICON = cfg?.icons?.fallbackIcon || '';
ICON_BASE = cfg?.icons?.basePath || '';
applyHeader(cfg);
applyTheme(cfg);
applyTypography(cfg);
applyLayout(cfg);
applyIcons(cfg);
preloadIcons(cfg);
document.title = (cfg.title || 'Home') + ' • Home';
(cfg.sections || []).forEach((sec, idx) => {
const node = renderSection(cfg, sec, idx);
elApp.appendChild(node);
});
attachIconFallback(); // after DOM is built
attachSearch();
// Re-apply spanning on resize (Firefox sometimes needs this)
window.addEventListener('resize', () => {
document.querySelectorAll('.grid').forEach(g => applyStretching(g));
});
(window.requestIdleCallback ? requestIdleCallback(checkAll) : setTimeout(checkAll, 200));
})
.catch(err => {
elApp.innerHTML = `<p style="color:#ff6b6b">Failed to load config.json: ${escapeHtml(err.message)}</p>`;
});
/* --------------------- Header / Meta / Favicons --------------------- */
function applyHeader(cfg){
const h = cfg.header || {};
const titleEl = document.querySelector('.titles h1');
const subEl = document.querySelector('.titles p');
const logoEl = document.querySelector('.logo img');
const searchWrap = document.querySelector('.search');
const searchInput = document.getElementById('search');
if (titleEl) titleEl.textContent = cfg.title || 'Home';
if (subEl) subEl.textContent = cfg.subtitle || '';
if (logoEl && h.logo) logoEl.src = h.logo;
document.documentElement.style.setProperty('--topbar-bg', h.background || '#101010');
if (searchWrap) searchWrap.style.display = h.showSearch === false ? 'none' : '';
if (searchInput && h.searchPlaceholder) searchInput.placeholder = h.searchPlaceholder;
// theme-color meta
setOrCreateMeta('meta-theme-color', 'theme-color', h.background || '#101010');
// Open Graph (title/image)
setOrCreateMeta('og-title', 'og:title', cfg.title || 'MakeArmy', true);
setOrCreateMeta('og-site_name', 'og:site_name', cfg.title || 'MakeArmy', true);
if (cfg.icons?.ogImage) setOrCreateMeta('og-image', 'og:image', cfg.icons.ogImage, true);
}
function applyIcons(cfg){
const ico = cfg.icons || {};
if (ico.faviconPng) setOrCreateFavicon('favicon-png', 'image/png', ico.faviconPng);
if (ico.faviconIco) setOrCreateFavicon('favicon-ico', '', ico.faviconIco);
}
function setOrCreateFavicon(id, type, href){
let link = document.getElementById(id);
if (!link) {
link = document.createElement('link');
link.id = id; link.rel = 'icon';
if (type) link.type = type;
document.head.appendChild(link);
}
link.href = href;
}
function setOrCreateMeta(id, name, content, isOg=false){
let meta = document.getElementById(id);
if (!meta) {
meta = document.createElement('meta');
meta.id = id;
if (isOg) meta.setAttribute('property', name); else meta.setAttribute('name', name);
document.head.appendChild(meta);
}
meta.setAttribute('content', content);
}
/* --------------------- Theme / Layout / Typography --------------------- */
function applyTheme(cfg){
const t = cfg.theme || {};
setVar('--bg0', t.background || '#202020');
setVar('--panel', t.panel || '#121212');
setVar('--tile', t.tile || '#202020');
setVar('--ink', t.ink || '#f2f2f2');
setVar('--ink-dim',t.inkDim || '#a9a9a9');
const acc = t.accents || {};
const A = {
blue: acc.blue || '#3a85ff',
green: acc.green || '#1fc66e',
red: acc.red || '#ff3f5d',
yellow: acc.yellow || '#ffdb33'
};
setVar('--blue', A.blue);
setVar('--green', A.green);
setVar('--red', A.red);
setVar('--yellow', A.yellow);
// RGB components for glow rgba()
const [br,bg,bb] = hexToRgb(A.blue);
const [gr,gg,gb] = hexToRgb(A.green);
const [rr,rg,rb] = hexToRgb(A.red);
const [yr,yg,yb] = hexToRgb(A.yellow);
setVar('--blue-rgb', `${br}, ${bg}, ${bb}`);
setVar('--green-rgb', `${gr}, ${gg}, ${gb}`);
setVar('--red-rgb', `${rr}, ${rg}, ${rb}`);
setVar('--yellow-rgb', `${yr}, ${yg}, ${yb}`);
const g = t.glow || {};
setVar('--glow-core', `${num(g.corePx,3)}px`);
setVar('--glow-blur', `${num(g.blurPx,7)}px`);
setVar('--glow-core-a', num(g.coreAlpha,0.95));
setVar('--glow-outer-a', num(g.outerAlpha,0.64));
}
function applyTypography(cfg){
const T = cfg.typography || {};
setVar('--section-title-px', num(T.sectionTitlePx, 22));
setVar('--item-label-px', num(T.itemLabelPx, 12));
}
// helper for px values
function px(v, d){ return `${num(v, d)}px`; }
function applyLayout(cfg){
const L = cfg.layout || {};
// keep unitless; CSS uses calc(var(--page-max) * 1px)
setVar('--page-max', num(L.pageMax, 2400));
// spacing + dimensions in px
setVar('--gap-sec', px(L.gapSections, 14));
setVar('--gap-item', px(L.gapItems, 10));
setVar('--section-min', px(L.sectionMin, 300));
setVar('--section-basis', px(L.sectionBasis, 320));
setVar('--section-max', px(L.sectionMax, 340));
setVar('--tile-h', px(L.tileHeight, 62));
// grid knobs (integers)
setVar('--grid-cols', String(num(L.gridCols, 6)));
setVar('--tile-colspan', String(num(L.tileColspan, 2)));
// icon box percent
setVar('--icon-box-pct', ICON_BOX_PCT);
}
/* --------------------- Render Sections / Items --------------------- */
function renderSection(cfg, section, idx){
const sec = document.createElement('section');
sec.className = 'section';
// Accent: number 1..4 uses global vars; hex sets custom per-section vars
let accentIndex = ((idx % 4) + 1);
if (typeof section.accent === 'number') {
accentIndex = Math.max(1, Math.min(4, section.accent));
sec.dataset.accent = String(accentIndex);
} else if (typeof section.accent === 'string' && section.accent.trim().startsWith('#')) {
const hex = section.accent.trim();
sec.classList.add('custom-accent');
sec.style.setProperty('--accent', hex);
const [r,g,b] = hexToRgb(hex);
sec.style.setProperty('--accent-rgb', `${r}, ${g}, ${b}`);
} else {
sec.dataset.accent = String(accentIndex);
}
sec.innerHTML = `
<div class="head">
<span>▸ ${escapeHtml(section.title || 'Section')}</span>
<span class="gear" aria-hidden="true">⋮</span>
</div>
<div class="grid"></div>
`;
const grid = sec.querySelector('.grid');
(section.items || []).forEach(item => {
const a = document.createElement('a');
a.className = 'tile';
a.href = item.href || '#';
a.rel = 'noopener noreferrer';
a.target = item.target || '_self';
a.dataset.search = (item.label + ' ' + (item.note || '') + ' ' + (section.title || '')).toLowerCase();
a.setAttribute('aria-label', item.label || 'Link');
a.innerHTML = `
<div class="icon">${renderIcon(item)}</div>
<div>
<div class="label">${escapeHtml(item.label || 'Link')}</div>
${item.note ? `<div class="sub">${escapeHtml(item.note)}</div>` : ''}
</div>
<div class="dot${item.check ? '' : ' ok'}" title="${item.check ? 'checking…' : 'static'}"></div>
`;
if (item.check) a.dataset.check = item.check; // status ping URL
grid.appendChild(a);
});
// Apply last-row stretching now that tiles exist
applyStretching(grid);
return sec;
}
function renderIcon(item){
const ic = item.icon;
const scale = (typeof item.icon_scale === 'number' && isFinite(item.icon_scale))
? item.icon_scale
: DEFAULT_ICON_SCALE;
if (typeof ic === 'string' && /\.(svg|png|webp|jpe?g|gif)$/i.test(ic)) {
const cls = item.icon_class ? ` class="${escapeAttr(item.icon_class)}"` : '';
const src = resolveIconSrc(ic);
return `<img src="${escapeAttr(src)}"${cls} alt="" loading="lazy" decoding="async" style="--icon-scale:${scale}">`;
}
if (item.icon_svg) return item.icon_svg;
return '🔗';
}
function resolveIconSrc(path){
// If absolute URL or root path, use as-is. Else prefix with ICON_BASE (if set).
if (/^https?:\/\//i.test(path) || path.startsWith('/')) return path;
if (ICON_BASE) return ICON_BASE.replace(/\/+$/,'/') + path.replace(/^\/+/, '');
return path;
}
/* --------------------- Search + Filter --------------------- */
function attachSearch(){
const tiles = Array.from(document.querySelectorAll('.tile'));
const sections = Array.from(document.querySelectorAll('.section'));
const run = () => {
const q = (document.getElementById('search')?.value || '').trim().toLowerCase();
tiles.forEach(t => t.classList.toggle('hidden', q && !t.dataset.search.includes(q)));
sections.forEach(s => {
const grid = s.querySelector('.grid');
const anyVisible = grid && Array.from(grid.querySelectorAll('.tile')).some(t => !t.classList.contains('hidden'));
s.classList.toggle('hidden', !anyVisible);
if (grid) applyStretching(grid);
});
};
document.getElementById('search')?.addEventListener('input', run);
run();
}
/* --------------------- Status "up" checks via <img> --------------------- */
function checkAll(){
document.querySelectorAll('.tile[data-check]').forEach(tile => {
const dot = tile.querySelector('.dot');
const url = tile.dataset.check + (tile.dataset.check.includes('?') ? '&' : '?') + 't=' + Date.now();
const img = new Image();
const fail = () => { dot.classList.add('down'); dot.title = 'down'; };
const ok = () => { dot.classList.remove('down'); dot.title = 'up'; };
const timeout = setTimeout(fail, 4500);
img.onload = () => { clearTimeout(timeout); ok(); };
img.onerror = () => { clearTimeout(timeout); fail(); };
img.src = url;
});
}
/* --------------------- Last-row stretching (robust) --------------------- */
function applyStretching(grid){
const tiles = Array.from(grid.querySelectorAll('.tile'))
.filter(t => !t.classList.contains('hidden'));
// Reset any previous manual spans
tiles.forEach(t => { t.style.gridColumn = ''; });
const gridCols = readCssInt(grid, '--grid-cols', 6);
const tileSpan = readCssInt(grid, '--tile-colspan', 2);
const perRow = Math.max(1, Math.floor(gridCols / tileSpan));
const remainder = tiles.length % perRow;
if (remainder === 1) {
// 1 leftover → full width
tiles[tiles.length - 1].style.gridColumn = `span ${gridCols}`;
} else if (remainder === 2) {
// 2 leftovers → 50/50
const half = Math.max(tileSpan, Math.floor(gridCols / 2));
tiles[tiles.length - 2].style.gridColumn = `span ${half}`;
tiles[tiles.length - 1].style.gridColumn = `span ${half}`;
}
}
function readCssInt(el, varName, fallback){
let v = parseInt(getComputedStyle(el).getPropertyValue(varName), 10);
if (!v) v = parseInt(getComputedStyle(document.documentElement).getPropertyValue(varName), 10);
return Number.isFinite(v) && v > 0 ? v : fallback;
}
/* --------------------- Preload top icons (perf) --------------------- */
function preloadIcons(cfg){
const limit = Math.max(0, Math.min(64, cfg?.icons?.preloadTopN ?? 16));
if (!limit) return;
const seen = new Set();
const paths = [];
(cfg.sections || []).forEach(s => (s.items || []).forEach(i => {
if (typeof i.icon === 'string' && /\.(svg|png|webp|jpe?g|gif)$/i.test(i.icon)) {
const src = resolveIconSrc(i.icon);
if (!seen.has(src)) { seen.add(src); paths.push(src); }
}
}));
paths.slice(0, limit).forEach(src => {
const link = document.createElement('link');
link.rel = 'preload'; link.as = 'image'; link.href = src;
document.head.appendChild(link);
});
}
/* --------------------- Icon fallback --------------------- */
function attachIconFallback(){
document.querySelectorAll('.icon img').forEach(img => {
img.addEventListener('error', () => {
if (img.dataset.fallbackApplied) return;
img.dataset.fallbackApplied = '1';
img.src = FALLBACK_ICON || BUILTIN_FALLBACK;
});
});
}
/* --------------------- Utils --------------------- */
function hexToRgb(hex){
let h = hex.replace('#','').trim();
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const numHex = parseInt(h,16);
const r = (numHex >> 16) & 255, g = (numHex >> 8) & 255, b = numHex & 255;
return [r,g,b];
}
function setVar(name, val){ document.documentElement.style.setProperty(name, String(val)); }
function num(v, d){ return (typeof v === 'number' && isFinite(v)) ? v : d; }
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
function escapeAttr(s){ return String(s).replace(/"/g, '&quot;'); }