/** * 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 * 3. parse the response, decide whether the data is empty * 4. either show the empty state, render the items, 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 30 times in `discover.js` * with subtle drift — different empty-state messages, inconsistent * error handling (some console.debug, some silently swallowed, some * leave the spinner spinning forever), inconsistent refresh-button * feedback. 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. * * MIGRATION STATUS: this is the foundation commit. Only `Recent * Releases` has been migrated as a proof. The other sections * (Your Artists, Your Albums, Seasonal, Fresh Tape, The Archives, * etc) still use their hand-rolled load functions in discover.js * and will migrate one section per commit. * * 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(); * * Future enhancements (not in this foundation commit): * - global error toast wrapper (so users see something when an * endpoint fails instead of the silent-empty-state default) * - registry-driven section list (so the dead-section audit * becomes registry edits, not section-by-section deletions) * - per-section "requires X primary source" gate */ (function () { 'use strict'; // Validate the config object up front — bad configs fail fast at // section-register time instead of silently breaking on load. 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)`); } if (typeof cfg.fetchUrl !== 'string' || !cfg.fetchUrl) { throw new Error(`[discover:${cfg.id}] config.fetchUrl required`); } if (typeof cfg.renderItems !== 'function') { throw new Error(`[discover:${cfg.id}] config.renderItems required (function)`); } } 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({ // Wrapper element to show/hide when section becomes empty. // Null = always visible, only the contents change. sectionEl: null, // Hide the whole section if the response is empty // (vs showing an empty-state message inside the carousel). hideWhenEmpty: false, // For sections that need to show data even on empty (e.g. // "Recent Releases" with "no recent releases" copy). // When true and items.length === 0, render the empty state. renderEmptyState: true, // HTTP method + options for the fetch call. Default GET, no // body. fetchOptions is a function so callers can compute it // at load time (e.g. read filter selects). fetchMethod: 'GET', fetchOptions: null, // Pull the items array from the response. Default looks // for `data.items` then `data.albums` then `data.artists`. extractItems: null, // Override the success check. Default: require data.success // when present, otherwise treat any 2xx as success. isSuccess: null, // Override empty-detection. Default: items.length === 0. isEmpty: null, // Post-render hook — attach event handlers, etc. onRendered: null, // Lifecycle copy. Empty string = no message. loadingMessage: 'Loading...', emptyMessage: 'Nothing to show', errorMessage: 'Failed to load', // CSS class names — let surfaces override for styling. loadingClass: 'discover-loading', emptyClass: 'discover-empty', errorClass: 'discover-empty', // When true, log full error stacks to console.error. Default // logs to console.debug — keeps the console quiet for users // while staying inspectable when devtools is open. verboseErrors: false, }, cfg); const state = { phase: 'idle', // idle | loading | rendered | empty | error lastData: null, lastError: null, inFlight: null, }; function _setHtml(el, html) { if (el) el.innerHTML = html; } function _showLoading() { const contentEl = _resolveEl(config.contentEl); if (!contentEl) return; const msg = config.loadingMessage ? `
${config.loadingMessage}
` : ''; _setHtml(contentEl, `${config.emptyMessage}
${config.errorMessage}