/** * Discover Section Controller * --------------------------- * * Owns the lifecycle every discover-page section already does by hand: * * 1. show a loading spinner in the carousel container * 2. fetch the section's endpoint (or use pre-fetched data) * 3. parse the response, decide whether the data is empty * 4. either show the empty state, render the items, show a stale * "still updating" state, or show an error * 5. wire any post-render handlers (download buttons, hover, etc) * 6. expose a refresh() method so the same lifecycle can re-fire * * Each section currently re-implements this by hand in `discover.js` * with subtle drift — different empty-state messages, inconsistent * error handling, inconsistent refresh-button feedback, no consistent * error toast. This controller is the "lift what's truly shared" * extraction: register a section once, the controller handles the * lifecycle, the section provides only its renderer. * * Renderers stay per-section because section data shapes legitimately * differ (album cards vs artist circles vs playlist tiles vs track * rows). The controller is the lifecycle wrapper around those * renderers, not a forced visual abstraction. * * USAGE: * * const ctrl = createDiscoverSectionController({ * id: 'recent-releases', * contentEl: '#recent-releases-carousel', * fetchUrl: '/api/discover/recent-releases', * extractItems: (data) => data.albums || [], * renderItems: (items, data, ctx) => buildCardsHtml(items), * onRendered: (ctx) => attachClickHandlers(ctx.contentEl), * loadingMessage: 'Loading recent releases...', * emptyMessage: 'No recent releases found', * errorMessage: 'Failed to load recent releases', * }); * ctrl.load(); * * EXTENSIONS: * * `fetchUrl` accepts a function returning a string for sections * whose endpoint depends on runtime state (e.g. seasonal playlist * keyed by `currentSeasonKey`). * * `data` lets a section bypass fetch entirely — the controller still * runs success / empty / render / onRendered, just without going to * the network. Use when a parent already fetched and just wants the * shared lifecycle. `data` may be a value or a `() => value` * function. Sections must supply EITHER `fetchUrl` OR `data`, not * both. * * `beforeLoad(ctx)` runs before the spinner shows. Useful for * ensuring `contentEl` exists (e.g. dynamically inserted sections) * or updating sibling headers / subtitles before any visual change. * * `onSuccess(data, ctx)` runs after the success check passes but * before isEmpty / isStale checks. Cleaner home for header text * updates that depend on response data (vs folding them into * renderItems). * * `isStale(items, data)` + `onStale(ctx)` give sections a third * render state for "data is empty but the upstream is still * discovering". Returning true from `isStale` renders the stale * state (default: spinner + "Updating..." copy, override via * `renderStale` or `staleMessage`) and fires `onStale` so the * section can start a poller. Stale wins over empty when both apply. * * `showErrorToast: true` opens a global `showToast(...)` on error * in addition to the in-section error block. Default off — sections * that have no recovery action shouldn't shout at the user. * * `manualDom: true` tells the controller to NOT write the * `renderItems` return value into `contentEl`. The renderer takes * full responsibility for the DOM (e.g. delegating to an existing * grid renderer that targets a child element). The renderer is * still called, just for its side-effects. Default false. */ (function () { 'use strict'; function _validateConfig(cfg) { if (!cfg || typeof cfg !== 'object') { throw new Error('createDiscoverSectionController: config required'); } if (typeof cfg.id !== 'string' || !cfg.id) { throw new Error('createDiscoverSectionController: config.id required (string)'); } if (typeof cfg.contentEl !== 'string' && !(cfg.contentEl instanceof Element)) { throw new Error(`[discover:${cfg.id}] config.contentEl required (selector or Element)`); } const hasFetch = (typeof cfg.fetchUrl === 'string' && cfg.fetchUrl) || typeof cfg.fetchUrl === 'function'; const hasData = cfg.data !== undefined; if (!hasFetch && !hasData) { throw new Error(`[discover:${cfg.id}] either config.fetchUrl or config.data required`); } if (hasFetch && hasData) { throw new Error(`[discover:${cfg.id}] config.fetchUrl and config.data are mutually exclusive`); } if (typeof cfg.renderItems !== 'function') { throw new Error(`[discover:${cfg.id}] config.renderItems required (function)`); } // Cin standard — explicit > implicit. Each section knows its own // response shape; the controller refusing to guess prevents // silent wrong-key bugs (e.g. an endpoint that returns // `data.results` getting auto-pulled instead of the intended // `data.tracks`). if (typeof cfg.extractItems !== 'function') { throw new Error(`[discover:${cfg.id}] config.extractItems required (function returning array)`); } } function _resolveEl(el) { if (el instanceof Element) return el; if (typeof el === 'string') return document.querySelector(el); return null; } /** * @param {Object} cfg - Section config (see file header for shape) * @returns {Object} Public API: { load, refresh, destroy, getState } */ function createDiscoverSectionController(cfg) { _validateConfig(cfg); const config = Object.assign({ sectionEl: null, hideWhenEmpty: false, renderEmptyState: true, fetchMethod: 'GET', fetchOptions: null, // Either fetchUrl (string or () => string) or data // (value or () => value). Validated mutually exclusive above. extractItems: null, isSuccess: null, isEmpty: null, // Stale = data is empty but upstream is still discovering. // Returning true here renders the stale state instead of // empty, and fires onStale so the section can poll. isStale: null, renderStale: null, staleMessage: 'Updating...', // Hooks beforeLoad: null, // (ctx) => void — before spinner shows onSuccess: null, // (data, ctx) => void — after success gate onStale: null, // (ctx) => void — when stale state renders onRendered: null, // (ctx) => void — after content renders // UX copy loadingMessage: 'Loading...', emptyMessage: 'Nothing to show', errorMessage: 'Failed to load', loadingClass: 'discover-loading', emptyClass: 'discover-empty', errorClass: 'discover-empty', staleClass: 'discover-loading', // Errors verboseErrors: false, showErrorToast: false, // also fire window.showToast on error // Renderer takes responsibility for the DOM — controller // calls renderItems but does NOT write its return value // into contentEl. Use when delegating to an existing // renderer that targets a child element. manualDom: false, }, cfg); const state = { phase: 'idle', // idle | loading | rendered | empty | stale | error lastData: null, lastError: null, inFlight: null, }; function _setHtml(el, html) { if (el) el.innerHTML = html; } function _ctx(extra) { return Object.assign( { contentEl: _resolveEl(config.contentEl), config }, extra || {}, ); } function _showLoading() { const contentEl = _resolveEl(config.contentEl); if (!contentEl) return; const msg = config.loadingMessage ? `

${config.loadingMessage}

` : ''; _setHtml(contentEl, `
${msg}
`); state.phase = 'loading'; } function _showEmpty() { const contentEl = _resolveEl(config.contentEl); if (!contentEl) return; if (config.hideWhenEmpty) { const sectionEl = _resolveEl(config.sectionEl); if (sectionEl) sectionEl.style.display = 'none'; state.phase = 'empty'; return; } if (config.renderEmptyState) { _setHtml(contentEl, `

${config.emptyMessage}

`); } else { _setHtml(contentEl, ''); } state.phase = 'empty'; } function _showStale(items, data) { const contentEl = _resolveEl(config.contentEl); if (!contentEl) return; _showSection(); // Custom renderStale wins. Otherwise default spinner + copy. let html; if (typeof config.renderStale === 'function') { try { html = config.renderStale(items, data, _ctx({ items, data })); } catch (err) { console.debug(`[discover:${config.id}] renderStale threw:`, err); html = null; } } if (html === null || html === undefined) { html = `

${config.staleMessage}

`; } _setHtml(contentEl, html); state.phase = 'stale'; if (typeof config.onStale === 'function') { try { config.onStale(_ctx({ items, data })); } catch (err) { console.debug(`[discover:${config.id}] onStale hook threw:`, err); } } } function _showError(error) { const contentEl = _resolveEl(config.contentEl); if (!contentEl) return; _setHtml(contentEl, `

${config.errorMessage}

`); state.phase = 'error'; state.lastError = error; const log = config.verboseErrors ? console.error : console.debug; log(`[discover:${config.id}]`, error); if (config.showErrorToast && typeof window.showToast === 'function') { try { window.showToast(config.errorMessage, 'error'); } catch (toastErr) { console.debug(`[discover:${config.id}] toast failed:`, toastErr); } } } function _showSection() { const sectionEl = _resolveEl(config.sectionEl); if (sectionEl) sectionEl.style.display = ''; } function _extractItems(data) { // Validation guarantees `extractItems` is a function. Wrap // the call so a renderer-side typo (returning undefined etc) // doesn't crash the loop — fall back to empty list. const items = config.extractItems(data); return Array.isArray(items) ? items : []; } function _isSuccess(data) { if (config.isSuccess) return config.isSuccess(data); if (data && Object.prototype.hasOwnProperty.call(data, 'success')) { return Boolean(data.success); } return true; } function _isEmpty(items, data) { if (config.isEmpty) return config.isEmpty(items, data); return !Array.isArray(items) || items.length === 0; } function _isStale(items, data) { if (typeof config.isStale !== 'function') return false; try { return Boolean(config.isStale(items, data)); } catch (err) { console.debug(`[discover:${config.id}] isStale threw:`, err); return false; } } function _resolveFetchUrl() { if (typeof config.fetchUrl === 'function') return config.fetchUrl(); return config.fetchUrl; } function _resolveStaticData() { if (typeof config.data === 'function') return config.data(); return config.data; } async function load() { // Coalesce concurrent loads — refresh() bypasses the coalesce. if (state.inFlight) return state.inFlight; // Run beforeLoad first so it can set up `contentEl` (dynamic // section creation) before the visibility check below. if (typeof config.beforeLoad === 'function') { try { config.beforeLoad(_ctx()); } catch (err) { console.debug(`[discover:${config.id}] beforeLoad hook threw:`, err); } } const contentEl = _resolveEl(config.contentEl); if (!contentEl) { console.debug(`[discover:${config.id}] contentEl not found, skipping load`); return Promise.resolve(); } _showLoading(); const promise = (async () => { try { let data; if (config.data !== undefined) { // No-fetch mode — parent already has the data. data = _resolveStaticData(); } else { const fetchOpts = (typeof config.fetchOptions === 'function') ? (config.fetchOptions() || {}) : {}; const init = Object.assign( { method: config.fetchMethod }, fetchOpts, ); const url = _resolveFetchUrl(); const resp = await fetch(url, init); if (!resp.ok) { throw new Error(`HTTP ${resp.status}`); } data = await resp.json(); } state.lastData = data; if (!_isSuccess(data)) { _showEmpty(); return; } if (typeof config.onSuccess === 'function') { try { config.onSuccess(data, _ctx({ data })); } catch (err) { console.debug(`[discover:${config.id}] onSuccess hook threw:`, err); } } const items = _extractItems(data); // Stale wins over empty — section is empty *now* but // upstream is still discovering, so show updating UI // rather than the bare "nothing here" copy. if (_isStale(items, data)) { _showStale(items, data); return; } if (_isEmpty(items, data)) { _showEmpty(); return; } _showSection(); const html = config.renderItems(items, data, _ctx({ items, data })); if (!config.manualDom) { // Default: controller owns the DOM. Renderer // returns the HTML, controller swaps it in. // Falsy returns become an empty container. _setHtml(contentEl, html || ''); } // manualDom mode: renderer already wrote whatever // it needs into the page; controller leaves // contentEl alone. state.phase = 'rendered'; if (typeof config.onRendered === 'function') { try { config.onRendered(_ctx({ items, data })); } catch (hookErr) { console.debug(`[discover:${config.id}] onRendered hook threw:`, hookErr); } } } catch (err) { _showError(err); } finally { state.inFlight = null; } })(); state.inFlight = promise; return promise; } async function refresh() { state.inFlight = null; return load(); } function destroy() { state.inFlight = null; state.lastData = null; state.lastError = null; state.phase = 'idle'; } function getState() { return { phase: state.phase, hasData: state.lastData !== null, error: state.lastError, }; } return { load, refresh, destroy, getState }; } window.createDiscoverSectionController = createDiscoverSectionController; })();