init
This commit is contained in:
commit
97f9a95415
21 changed files with 2963 additions and 0 deletions
163
public/js/app.js
Normal file
163
public/js/app.js
Normal file
|
@ -0,0 +1,163 @@
|
|||
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' }
|
||||
];
|
||||
|
||||
const navEl = document.getElementById('nav');
|
||||
const viewEl = document.getElementById('view');
|
||||
const themeBtn= document.getElementById('themeToggle');
|
||||
initTheme(themeBtn);
|
||||
|
||||
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));
|
||||
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);
|
||||
}
|
||||
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();
|
Loading…
Add table
Add a link
Reference in a new issue