193 lines
6.3 KiB
JavaScript
193 lines
6.3 KiB
JavaScript
import {el, initTheme, enhanceSelects} from './util.js';
|
|
|
|
const CALCS = [
|
|
{ id:'interest', name:'Interest (Simple & Compound)', about:'Simple/compound interest', path:'../calculators/interest.js' },
|
|
{ id:'raid', name:'RAID', about:'Usable capacity', path:'../calculators/raid.js' },
|
|
{ id:'bandwidth', name:'Bandwidth', about:'Bits↔bytes unit conv.', path:'../calculators/bandwidth.js' },
|
|
{ id:'nmea', name:'NMEA', about:'0183 XOR checksum', path:'../calculators/nmea.js' },
|
|
{ id:'currency', name:'Currency Converter', about:'Convert between currencies', path:'../calculators/currency.js' },
|
|
{ id:'subnet', name:'IP Subnet', about:'IPv4/IPv6 subnet calculations', path:'../calculators/subnet.js' }
|
|
];
|
|
|
|
const navEl = document.getElementById('nav');
|
|
const viewEl = document.getElementById('view');
|
|
const themeBtn= document.getElementById('themeToggle');
|
|
const navToggleBtn = document.getElementById('navToggle');
|
|
initTheme(themeBtn);
|
|
|
|
// Mobile navigation toggle
|
|
navToggleBtn.addEventListener('click', () => {
|
|
navEl.classList.toggle('mobile-active');
|
|
});
|
|
|
|
// Close mobile nav when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!navEl.contains(e.target) && !navToggleBtn.contains(e.target)) {
|
|
navEl.classList.remove('mobile-active');
|
|
}
|
|
});
|
|
|
|
// Close mobile nav when clicking on a nav link
|
|
navEl.addEventListener('click', (e) => {
|
|
const a = e.target.closest('a[data-calc]');
|
|
if (a) {
|
|
navEl.classList.remove('mobile-active');
|
|
}
|
|
});
|
|
|
|
const moduleCache = new Map();
|
|
const viewCache = new Map();
|
|
|
|
function metaById(id){ return CALCS.find(c=>c.id===id) || CALCS[0] }
|
|
|
|
/* ---------- History API routing (path + query) ---------- */
|
|
function normalize(id, params){
|
|
const qs = params && [...params].length ? `?${params.toString()}` : '';
|
|
return `/${id}${qs}`;
|
|
}
|
|
function parseRoute(){
|
|
const path = location.pathname.replace(/^\/+/, '');
|
|
const id = path || 'interest';
|
|
const params = new URLSearchParams(location.search);
|
|
return { id, params };
|
|
}
|
|
function setRoute(id, params, {replace=true}={}){
|
|
const target = normalize(id, params || new URLSearchParams());
|
|
const current = `${location.pathname}${location.search}`;
|
|
if (target === current) return; // no-op if unchanged
|
|
if (replace) history.replaceState(null, '', target);
|
|
else history.pushState(null, '', target);
|
|
mount(); // re-render
|
|
}
|
|
|
|
function formToParams(card){
|
|
const p = new URLSearchParams();
|
|
card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{
|
|
let v = el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value;
|
|
if (v !== '') p.set(el.name, v);
|
|
});
|
|
return p;
|
|
}
|
|
function paramsToForm(card, params){
|
|
let touched = false;
|
|
card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{
|
|
const k = el.name;
|
|
if (!params.has(k)) return;
|
|
const v = params.get(k);
|
|
if (el.type === 'checkbox'){
|
|
const val = (v==='1' || v==='true');
|
|
if(el.checked !== val){ el.checked = val; touched = true; }
|
|
}else{
|
|
if(el.value !== v){ el.value = v; touched = true; }
|
|
}
|
|
});
|
|
if (touched) card.dispatchEvent(new Event('input', {bubbles:true}));
|
|
}
|
|
|
|
/* ---------- Nav: anchors + minimal history ---------- */
|
|
function renderNav(active){
|
|
navEl.innerHTML = '';
|
|
const ul = el('ul',{class:'navlist'});
|
|
for(const c of CALCS){
|
|
const a = el('a', {
|
|
href: `/${c.id}`,
|
|
'data-calc': c.id,
|
|
'aria-current': c.id===active ? 'page' : null
|
|
}, c.name);
|
|
ul.append(el('li',{}, a));
|
|
}
|
|
navEl.append(ul);
|
|
}
|
|
|
|
// delegate clicks so we can decide push vs replace
|
|
navEl.addEventListener('click', (e)=>{
|
|
const a = e.target.closest('a[data-calc]');
|
|
if(!a) return;
|
|
// allow new tab/window/defaults
|
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
e.preventDefault();
|
|
const id = a.dataset.calc;
|
|
// shift-click = commit to history (push); otherwise replace (minimal history)
|
|
setRoute(id, null, {replace: !e.shiftKey});
|
|
});
|
|
|
|
function loadModule(spec){
|
|
if(!moduleCache.has(spec)) moduleCache.set(spec, import(spec));
|
|
return moduleCache.get(spec);
|
|
}
|
|
|
|
async function ensureMounted(id){
|
|
if(viewCache.has(id)) return viewCache.get(id);
|
|
const meta = metaById(id);
|
|
const card = el('section',{class:'card'});
|
|
viewCache.set(id, card);
|
|
|
|
// placeholder heading
|
|
card.append(el('h2',{}, meta.name), el('div',{class:'muted'}, meta.about||''));
|
|
try{
|
|
const mod = await loadModule(meta.path);
|
|
const calc = mod.default;
|
|
card.innerHTML = '';
|
|
card.append(el('h2',{}, calc.name || meta.name));
|
|
if(calc.about) card.append(el('div',{class:'muted'}, calc.about));
|
|
|
|
// Update page title with calculator name
|
|
document.title = `${calc.name || meta.name} - calculator.127local.net`;
|
|
|
|
calc.render(card);
|
|
enhanceSelects(card);
|
|
}catch(e){
|
|
console.error(e);
|
|
card.innerHTML = `<div class="muted">Failed to load calculator. Check console.</div>`;
|
|
}
|
|
return card;
|
|
}
|
|
|
|
function attachUrlSync(card, id){
|
|
// Debounced replaceState on input changes (never pushes to history)
|
|
let t;
|
|
card.addEventListener('input', ()=>{
|
|
clearTimeout(t);
|
|
t = setTimeout(()=>{
|
|
const newParams = formToParams(card);
|
|
const {id:curId, params:curParams} = parseRoute();
|
|
const sameId = (curId === id);
|
|
const sameQs = sameId && newParams.toString() === curParams.toString();
|
|
if(!sameQs) setRoute(id, newParams, {replace:true});
|
|
}, 150);
|
|
});
|
|
}
|
|
|
|
async function show(id, params){
|
|
renderNav(id);
|
|
for(const [cid, card] of viewCache.entries()){
|
|
card.style.display = (cid===id) ? '' : 'none';
|
|
}
|
|
if(!viewCache.has(id)){
|
|
const card = await ensureMounted(id);
|
|
viewEl.append(card);
|
|
attachUrlSync(card, id);
|
|
} else {
|
|
// Update title for already cached calculators
|
|
const meta = metaById(id);
|
|
document.title = `${meta.name} - calculator.127local.net`;
|
|
}
|
|
paramsToForm(viewCache.get(id), params);
|
|
}
|
|
|
|
async function mount(){
|
|
const {id, params} = parseRoute();
|
|
// normalize root to /interest once
|
|
if(!location.pathname || location.pathname === '/' ){
|
|
setRoute('interest', params, {replace:true});
|
|
return;
|
|
}
|
|
await show(id, params);
|
|
|
|
// idle prefetch
|
|
const idle = window.requestIdleCallback || ((fn)=>setTimeout(fn,300));
|
|
idle(()=> CALCS.forEach(c=> loadModule(c.path).catch(()=>{})));
|
|
}
|
|
|
|
addEventListener('popstate', mount); // back/forward
|
|
mount();
|