/* Application bootstrap for the static, client-side metadata browser. - Downloads a zipped SQLite DB (db.zip) with progress and unzips it in-memory - Caches the uncompressed DB bytes in IndexedDB for reuse on next load - Loads sql.js (WASM) and opens the database from the cached bytes - Swaps interchangeable UX elements (views) in a viewport, with fade transitions Assumptions: - A ZIP archive is hosted at `/db.zip` containing a single `.sqlite` file. - We use `fflate` (UMD) from a CDN to unzip after download (keeps implementation minimal). - We use `sql.js` from a CDN; the WASM is located via `locateFile` (see initSql()). - We store raw DB bytes in IndexedDB (localStorage is too small for large DBs). */ (function () { // Use a relative URL so the app works when hosted under a subdirectory. const DB_ZIP_URL = './db.zip'; const IDB_NAME = 'mp3com-meta-browser'; const IDB_STORE = 'files'; const IDB_KEY = 'mp3com-db-bytes-v1'; // bump if format changes // Viewport layers const $uxRoot = document.getElementById('ux-root'); const $uxOverlays = document.getElementById('ux-overlays'); // Fade helpers function fadeIn(el) { return new Promise((resolve) => { el.classList.add('fade'); el.classList.add('fade-hidden'); requestAnimationFrame(() => { el.classList.add('fade-visible'); el.classList.remove('fade-hidden'); const onEnd = (e) => { if (e.propertyName === 'opacity') { el.removeEventListener('transitionend', onEnd); resolve(); } }; el.addEventListener('transitionend', onEnd); }); }); } function fadeOut(el) { return new Promise((resolve) => { el.classList.add('fade'); el.classList.remove('fade-visible'); el.classList.add('fade-hidden'); const onEnd = (e) => { if (e.propertyName === 'opacity') { el.removeEventListener('transitionend', onEnd); resolve(); } }; el.addEventListener('transitionend', onEnd); }); } // UX Manager const UX = (() => { let currentBase = null; // { view, el } const overlayStack = []; // [{ view, el, lastFocus }] function ensureViewEl(view) { if (!view || !view.el) throw new Error('Invalid view'); view.el.classList.add('ux-view'); return view.el; } async function replace(view) { const el = ensureViewEl(view); if (currentBase) { await fadeOut(currentBase.el); $uxRoot.removeChild(currentBase.el); if (typeof currentBase.view.destroy === 'function') currentBase.view.destroy(); } $uxRoot.appendChild(el); await fadeIn(el); currentBase = { view, el }; if (typeof view.onShow === 'function') view.onShow(); } async function openOverlay(view) { const el = ensureViewEl(view); el.classList.add('ux-view--overlay'); const lastFocus = document.activeElement; $uxOverlays.appendChild(el); await fadeIn(el); overlayStack.push({ view, el, lastFocus }); $uxRoot.setAttribute('aria-hidden', 'true'); if (typeof view.onShow === 'function') view.onShow(); } async function closeTop() { const top = overlayStack.pop(); if (!top) return; const { view, el, lastFocus } = top; await fadeOut(el); if (el.parentNode) el.parentNode.removeChild(el); if (typeof view.destroy === 'function') view.destroy(); if (overlayStack.length === 0) $uxRoot.removeAttribute('aria-hidden'); if (lastFocus && typeof lastFocus.focus === 'function') lastFocus.focus(); } window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && overlayStack.length) { e.preventDefault(); closeTop(); } }); return { replace, openOverlay, closeTop }; })(); // --- IndexedDB helpers (minimal, no external deps) --- function idbOpen() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function idbGet(key) { const db = await idbOpen(); return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).get(key); req.onsuccess = () => resolve(req.result ?? null); req.onerror = () => reject(req.error); }); } async function idbSet(key, value) { const db = await idbOpen(); return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readwrite'); const req = tx.objectStore(IDB_STORE).put(value, key); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } // --- Download and unzip db.zip --- let loader; // set after factory creation async function fetchZipWithProgress(url) { loader.setStep('Downloading database…'); loader.setDetail('Starting download'); const resp = await fetch(url, { cache: 'force-cache' }); if (!resp.ok || !resp.body) throw new Error('Failed to fetch DB archive'); const total = parseInt(resp.headers.get('content-length') || '0', 10) || 0; const reader = resp.body.getReader(); const chunks = []; let received = 0; loader.setProgress(0, total || undefined); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.byteLength; loader.setProgress(received, total || undefined); if (total) loader.setDetail(`${((received / total) * 100).toFixed(1)}% • ${formatBytes(received)} / ${formatBytes(total)}`); else loader.setDetail(`${formatBytes(received)} downloaded`); } const zipBytes = concatUint8(chunks, received); return zipBytes; } function concatUint8(chunks, totalLen) { const out = new Uint8Array(totalLen); let offset = 0; for (const c of chunks) { out.set(c, offset); offset += c.byteLength; } return out; } function formatBytes(bytes) { const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let val = bytes; while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; } return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } async function unzipSqlite(zipBytes) { loader.setStep('Unpacking database…'); loader.setDetail('Decompressing ZIP'); const { unzipSync } = window.fflate || {}; if (!unzipSync) throw new Error('Unzip library not loaded'); const files = unzipSync(zipBytes); const names = Object.keys(files); if (!names.length) throw new Error('Empty ZIP archive'); let choice = names.find((n) => /\.sqlite$/i.test(n)) || names[0]; const dbBytes = files[choice]; loader.setDetail(`Unpacked ${choice} • ${formatBytes(dbBytes.byteLength)}`); loader.setProgress(100, 100); return dbBytes; } // --- SQL.js init --- async function initSql() { if (typeof initSqlJs !== 'function') throw new Error('sql.js not loaded'); loader.setStep('Initializing SQLite…'); loader.setDetail('Loading WASM'); const SQL = await initSqlJs({ locateFile: (file) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/${file}`, }); return SQL; } // --- Templates & Views --- function instantiateTemplate(id) { const tpl = document.getElementById(id); if (!tpl || !('content' in tpl)) throw new Error(`Missing template: ${id}`); const frag = tpl.content.cloneNode(true); const root = frag.firstElementChild; return root; } function createLoaderView() { const el = instantiateTemplate('tpl-loader'); const $step = el.querySelector('[data-ref="step"]'); const $detail = el.querySelector('[data-ref="detail"]'); const $progress = el.querySelector('[data-ref="progress"]'); return { kind: 'base', el, setStep(text) { $step.textContent = text; }, setDetail(text) { $detail.textContent = text; }, setProgress(value, max) { if (typeof max === 'number' && max > 0) { $progress.max = max; $progress.value = value; const pct = Math.floor((value / max) * 100); $progress.textContent = pct + '%'; } else { $progress.removeAttribute('max'); $progress.value = 0; $progress.textContent = '…'; } }, destroy() {}, }; } function createSearchView(db) { const el = instantiateTemplate('tpl-search'); const $form = el.querySelector('[data-ref="form"]'); const $q = el.querySelector('[data-ref="q"]'); const $results = el.querySelector('[data-ref="results"]'); function renderStub(qVal) { if (!qVal) { $results.innerHTML = '

Type to search…

'; } else { $results.innerHTML = `

Search stub — ready for query: ${escapeHtml(qVal)}

`; } } function enable() { $q.disabled = false; $q.removeAttribute('aria-disabled'); $q.focus(); } $form.addEventListener('submit', (e) => e.preventDefault()); $q.addEventListener('input', () => { const q = $q.value.trim(); renderStub(q); // Future: db.prepare / db.exec queries }); renderStub(''); return { kind: 'base', el, onShow() { enable(); }, destroy() {}, }; } // --- Main bootstrap --- loader = createLoaderView(); UX.replace(loader); (async function main() { try { loader.setStep('Checking cache…'); loader.setDetail('Looking for cached database'); let dbBytes = await idbGet(IDB_KEY); if (dbBytes) { loader.setStep('Using cached database'); loader.setDetail(`Found ${formatBytes(dbBytes.byteLength)} in IndexedDB`); loader.setProgress(100, 100); } else { const zipBytes = await fetchZipWithProgress(DB_ZIP_URL); dbBytes = await unzipSqlite(zipBytes); loader.setDetail('Caching database for future loads'); await idbSet(IDB_KEY, dbBytes); } const SQL = await initSql(); loader.setDetail('Opening database'); const db = new SQL.Database(dbBytes); const search = createSearchView(db); await UX.replace(search); window.__db = db; } catch (err) { console.error(err); try { loader.setStep('Initialization failed'); loader.setDetail(String(err && err.message ? err.message : err)); const pb = loader.el.querySelector('[data-ref="progress"]'); if (pb) { pb.classList.remove('is-primary'); pb.classList.add('is-danger'); } } catch (_) {} } })(); function escapeHtml(s) { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/\"/g, '"') .replace(/'/g, '''); } })();