You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/webui/static/discover-section-controller.js

453 lines
18 KiB

/**
* 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
? `<p>${config.loadingMessage}</p>`
: '';
_setHtml(contentEl, `
<div class="${config.loadingClass}">
<div class="loading-spinner"></div>
${msg}
</div>
`);
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, `
<div class="${config.emptyClass}">
<p>${config.emptyMessage}</p>
</div>
`);
} 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 = `
<div class="${config.staleClass}">
<div class="loading-spinner"></div>
<p>${config.staleMessage}</p>
</div>
`;
}
_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, `
<div class="${config.errorClass}">
<p>${config.errorMessage}</p>
</div>
`);
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;
})();