373 lines
14 KiB
JavaScript
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
|
|
function escapeAttr(s){ return String(s).replace(/"/g, '"'); }
|