338 lines
11 KiB
JavaScript
338 lines
11 KiB
JavaScript
/*
|
|
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 = '<p class="has-text-grey">Type to search…</p>';
|
|
} else {
|
|
$results.innerHTML = `<p class="has-text-grey">Search stub — ready for query: <code>${escapeHtml(qVal)}</code></p>`;
|
|
}
|
|
}
|
|
|
|
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, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
})();
|
|
|