Introduce UX viewport + templates; add UX manager for swappable/stackable views; refactor loader/search into view factories; extend site.css with viewport helpers.
This commit is contained in:
parent
07ce787d9c
commit
85212a12fb
3 changed files with 219 additions and 131 deletions
39
index.html
39
index.html
|
@ -16,40 +16,45 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Loader/progress area: shown while DB is fetched/unpacked/initialized -->
|
||||
<section id="loader" class="section fade fade-visible" aria-live="polite">
|
||||
<!-- Viewport: a single visible container that holds the active UX element -->
|
||||
<section id="app" class="section">
|
||||
<div class="container">
|
||||
<div class="box">
|
||||
<p id="loader-step" class="mb-2">Preparing…</p>
|
||||
<!-- Bulma progress bar: we update value/max via JS. No inline styles. -->
|
||||
<progress id="loader-progress" class="progress is-primary" value="0" max="100">0%</progress>
|
||||
<p id="loader-detail" class="is-size-7 has-text-grey">Starting…</p>
|
||||
<div id="viewport" class="ux-viewport">
|
||||
<div id="ux-root" class="ux-root"></div>
|
||||
<div id="ux-overlays" class="ux-overlays" aria-live="polite" aria-atomic="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main app UI: hidden until DB is ready -->
|
||||
<!-- App starts hidden and will fade in after loader completes. -->
|
||||
<section id="app" class="section fade" hidden>
|
||||
<div class="container">
|
||||
<!-- Templates for interchangeable, stackable UX elements -->
|
||||
<template id="tpl-loader">
|
||||
<div class="ux-view fade">
|
||||
<div class="box" role="status" aria-live="polite">
|
||||
<p class="mb-2" data-ref="step">Preparing…</p>
|
||||
<progress class="progress is-primary" value="0" max="100" data-ref="progress">0%</progress>
|
||||
<p class="is-size-7 has-text-grey" data-ref="detail">Starting…</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-search">
|
||||
<div class="ux-view fade">
|
||||
<div class="box">
|
||||
<form id="search-form" class="mb-4" role="search" aria-label="Track search">
|
||||
<form class="mb-4" role="search" aria-label="Track search" data-ref="form">
|
||||
<div class="field">
|
||||
<label class="label" for="q">Search</label>
|
||||
<div class="control">
|
||||
<input id="q" name="q" class="input" type="search" placeholder="Search artist, title, album, genre…" disabled aria-disabled="true" />
|
||||
<input id="q" name="q" class="input" type="search" placeholder="Search artist, title, album, genre…" disabled aria-disabled="true" data-ref="q" />
|
||||
</div>
|
||||
<p class="help">Powered by in-browser SQLite FTS; no network queries.</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Results stub for now -->
|
||||
<div id="results" class="content">
|
||||
<div class="content" data-ref="results">
|
||||
<p class="has-text-grey">Database not initialized yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- Third-party libs loaded from CDNs (no trackers). Pinned versions. -->
|
||||
<!-- fflate: tiny ZIP library used to unzip the downloaded DB archive client-side. -->
|
||||
|
|
305
script.js
305
script.js
|
@ -3,7 +3,7 @@
|
|||
- 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
|
||||
- Hides the loader and shows a stub UI once ready
|
||||
- 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.
|
||||
|
@ -14,76 +14,103 @@
|
|||
|
||||
(function () {
|
||||
// Use a relative URL so the app works when hosted under a subdirectory.
|
||||
// If you move the archive, keep paths relative (e.g., './assets/db.zip').
|
||||
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
|
||||
|
||||
// UI elements
|
||||
const $loader = document.getElementById('loader');
|
||||
const $loaderStep = document.getElementById('loader-step');
|
||||
const $loaderDetail = document.getElementById('loader-detail');
|
||||
const $loaderProgress = document.getElementById('loader-progress');
|
||||
const $app = document.getElementById('app');
|
||||
const $q = document.getElementById('q');
|
||||
const $results = document.getElementById('results');
|
||||
// Viewport layers
|
||||
const $uxRoot = document.getElementById('ux-root');
|
||||
const $uxOverlays = document.getElementById('ux-overlays');
|
||||
|
||||
// Small utility: sequentially fade out one element and fade in another.
|
||||
// We keep DOM flow predictable: hide via `hidden` only after fade-out completes.
|
||||
function fadeSwap(outEl, inEl) {
|
||||
// Fade helpers
|
||||
function fadeIn(el) {
|
||||
return new Promise((resolve) => {
|
||||
// Ensure both have the base fade class
|
||||
outEl.classList.add('fade');
|
||||
inEl.classList.add('fade');
|
||||
|
||||
// Start fade-out for the current element
|
||||
outEl.classList.remove('fade-visible');
|
||||
outEl.classList.add('fade-hidden');
|
||||
|
||||
const onOutEnd = () => {
|
||||
outEl.removeEventListener('transitionend', onOutEnd);
|
||||
outEl.hidden = true; // remove from layout after fade completes
|
||||
|
||||
// Prepare incoming element at opacity 0, then show and fade to 1
|
||||
inEl.hidden = false;
|
||||
inEl.classList.add('fade-hidden');
|
||||
// Next frame to ensure the opacity 0 state is committed before we flip to visible
|
||||
requestAnimationFrame(() => {
|
||||
inEl.classList.remove('fade-hidden');
|
||||
inEl.classList.add('fade-visible');
|
||||
// Resolve shortly after the fade-in, no need to wait full duration strictly
|
||||
inEl.addEventListener('transitionend', function onInEnd(e) {
|
||||
if (e.propertyName === 'opacity') {
|
||||
inEl.removeEventListener('transitionend', onInEnd);
|
||||
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();
|
||||
}
|
||||
};
|
||||
outEl.addEventListener('transitionend', onOutEnd);
|
||||
el.addEventListener('transitionend', onEnd);
|
||||
});
|
||||
}
|
||||
|
||||
function setStep(text) {
|
||||
$loaderStep.textContent = text;
|
||||
}
|
||||
function setDetail(text) {
|
||||
$loaderDetail.textContent = text;
|
||||
}
|
||||
function setProgress(value, max) {
|
||||
if (typeof max === 'number' && max > 0) {
|
||||
$loaderProgress.max = max;
|
||||
$loaderProgress.value = value;
|
||||
const pct = Math.floor((value / max) * 100);
|
||||
$loaderProgress.textContent = pct + '%';
|
||||
} else {
|
||||
// Unknown total; use a simple textual fallback
|
||||
$loaderProgress.removeAttribute('max');
|
||||
$loaderProgress.value = 0;
|
||||
$loaderProgress.textContent = '…';
|
||||
// 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() {
|
||||
|
@ -117,9 +144,10 @@
|
|||
}
|
||||
|
||||
// --- Download and unzip db.zip ---
|
||||
let loader; // set after factory creation
|
||||
async function fetchZipWithProgress(url) {
|
||||
setStep('Downloading database…');
|
||||
setDetail('Starting download');
|
||||
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');
|
||||
|
@ -128,15 +156,15 @@
|
|||
const reader = resp.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
setProgress(0, total || undefined);
|
||||
loader.setProgress(0, total || undefined);
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.byteLength;
|
||||
setProgress(received, total || undefined);
|
||||
if (total) setDetail(`${((received / total) * 100).toFixed(1)}% • ${formatBytes(received)} / ${formatBytes(total)}`);
|
||||
else setDetail(`${formatBytes(received)} downloaded`);
|
||||
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;
|
||||
|
@ -164,89 +192,137 @@
|
|||
}
|
||||
|
||||
async function unzipSqlite(zipBytes) {
|
||||
setStep('Unpacking database…');
|
||||
setDetail('Decompressing ZIP');
|
||||
// fflate is provided globally as window.fflate
|
||||
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); // returns { [name]: Uint8Array }
|
||||
// Heuristic: pick the first .sqlite (or first entry if none matches)
|
||||
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];
|
||||
setDetail(`Unpacked ${choice} • ${formatBytes(dbBytes.byteLength)}`);
|
||||
setProgress(100, 100);
|
||||
loader.setDetail(`Unpacked ${choice} • ${formatBytes(dbBytes.byteLength)}`);
|
||||
loader.setProgress(100, 100);
|
||||
return dbBytes;
|
||||
}
|
||||
|
||||
// --- SQL.js init ---
|
||||
async function initSql() {
|
||||
// sql.js (UMD) exposes initSqlJs globally; WASM resolved via locateFile
|
||||
if (typeof initSqlJs !== 'function') throw new Error('sql.js not loaded');
|
||||
setStep('Initializing SQLite…');
|
||||
setDetail('Loading WASM');
|
||||
loader.setStep('Initializing SQLite…');
|
||||
loader.setDetail('Loading WASM');
|
||||
const SQL = await initSqlJs({
|
||||
// Pin to the same CDN base as the script tag above
|
||||
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 {
|
||||
// 1) Try cache first
|
||||
setStep('Checking cache…');
|
||||
setDetail('Looking for cached database');
|
||||
loader.setStep('Checking cache…');
|
||||
loader.setDetail('Looking for cached database');
|
||||
let dbBytes = await idbGet(IDB_KEY);
|
||||
if (dbBytes) {
|
||||
setStep('Using cached database');
|
||||
setDetail(`Found ${formatBytes(dbBytes.byteLength)} in IndexedDB`);
|
||||
setProgress(100, 100);
|
||||
loader.setStep('Using cached database');
|
||||
loader.setDetail(`Found ${formatBytes(dbBytes.byteLength)} in IndexedDB`);
|
||||
loader.setProgress(100, 100);
|
||||
} else {
|
||||
// 2) Fetch and unzip, then cache
|
||||
const zipBytes = await fetchZipWithProgress(DB_ZIP_URL);
|
||||
dbBytes = await unzipSqlite(zipBytes);
|
||||
setDetail('Caching database for future loads');
|
||||
loader.setDetail('Caching database for future loads');
|
||||
await idbSet(IDB_KEY, dbBytes);
|
||||
}
|
||||
|
||||
// 3) Initialize SQL.js and open the DB
|
||||
const SQL = await initSql();
|
||||
setDetail('Opening database');
|
||||
loader.setDetail('Opening database');
|
||||
const db = new SQL.Database(dbBytes);
|
||||
|
||||
// Very small sanity check (does not assume tables exist yet). If desired, we could
|
||||
// run `SELECT name FROM sqlite_master LIMIT 1` here. Keep minimal per project goals.
|
||||
|
||||
// 4) Reveal app UI with a fade transition
|
||||
// Hide loader first, then fade the app in to avoid both showing at once.
|
||||
await fadeSwap($loader, $app);
|
||||
$q.disabled = false; // enable search once visible
|
||||
$q.removeAttribute('aria-disabled');
|
||||
$results.innerHTML = '<p class="has-text-success">Database initialized. Stub UI ready.</p>';
|
||||
|
||||
// 5) Wire a minimal, no-op search stub (ready for future expansion)
|
||||
document.getElementById('search-form').addEventListener('submit', (e) => e.preventDefault());
|
||||
$q.addEventListener('input', () => {
|
||||
// Placeholder behavior; later this will issue FTS queries via db.prepare / db.exec
|
||||
const q = $q.value.trim();
|
||||
if (!q) {
|
||||
$results.innerHTML = '<p class="has-text-grey">Type to search…</p>';
|
||||
return;
|
||||
}
|
||||
$results.innerHTML = `<p class="has-text-grey">Search stub — ready for query: <code>${escapeHtml(q)}</code></p>`;
|
||||
});
|
||||
|
||||
// Expose db for future debugging in console (non-production convenience)
|
||||
const search = createSearchView(db);
|
||||
await UX.replace(search);
|
||||
window.__db = db;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStep('Initialization failed');
|
||||
setDetail(String(err && err.message ? err.message : err));
|
||||
$loaderProgress.classList.remove('is-primary');
|
||||
$loaderProgress.classList.add('is-danger');
|
||||
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 (_) {}
|
||||
}
|
||||
})();
|
||||
|
||||
|
@ -255,7 +331,8 @@
|
|||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
6
site.css
6
site.css
|
@ -5,3 +5,9 @@
|
|||
.fade-visible { opacity: 1; visibility: visible; }
|
||||
.fade-hidden { opacity: 0; visibility: hidden; }
|
||||
|
||||
/* Viewport + layers */
|
||||
.ux-viewport { position: relative; }
|
||||
.ux-root { min-height: 40vh; }
|
||||
.ux-overlays { position: absolute; inset: 0; pointer-events: none; }
|
||||
.ux-view { width: 100%; }
|
||||
.ux-view--overlay { position: absolute; inset: 0; z-index: 10; pointer-events: auto; }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue