mirror of https://github.com/Nezreka/SoulSync.git
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.
453 lines
18 KiB
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;
|
|
})();
|