/** * 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, `${config.emptyMessage}
${config.staleMessage}
${config.errorMessage}