PHP Classes

File: public_html/js/interest-picker.js

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   public_html/js/interest-picker.js   Download  
File: public_html/js/interest-picker.js
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 25,615 bytes
 

Contents

Class file image Download
/** * InterestPicker ? reusable interest selection/subscription widget. * * Modes: * 'subscribe' (default) ? renders a grid of interest cards each with a * Subscribe / Unsubscribe toggle that persists to * the server via POST /api/interests/{id}/subscribe * and /unsubscribe. * * 'select' ? renders the same grid but with checkboxes so the * caller can collect a list of selected interest IDs * (e.g. for "Find echo areas by interest"). * Call getSelectedIds() to read the current selection. * Pass onSelectionChange(ids) to react immediately. * * Usage (subscribe mode): * const picker = new InterestPicker('my-container'); * picker.load(); * * Usage (select mode): * const picker = new InterestPicker('my-container', { * mode: 'select', * selectedIds: [3, 7], * onSelectionChange: (ids) => console.log(ids), * }); * picker.load(); * // later: picker.getSelectedIds() */ class InterestPicker { /** * @param {string|HTMLElement} container Element ID string or DOM node. * @param {object} options * @param {'subscribe'|'select'} [options.mode='subscribe'] * @param {number[]} [options.selectedIds=[]] Pre-selected IDs (select mode). * @param {function} [options.onSelectionChange] Called with array of selected IDs. */ constructor(container, options = {}) { this._el = typeof container === 'string' ? document.getElementById(container) : container; this._mode = options.mode || 'subscribe'; this._selectedIds = new Set(options.selectedIds || []); this._onSelectionChange = options.onSelectionChange || null; this._interests = []; this._busy = new Set(); // IDs currently being toggled } // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- /** Fetch interests from the server and render. */ async load() { this._renderLoading(); try { const resp = await fetch('/api/interests'); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); this._interests = data.interests || []; // In subscribe mode, seed selection from server's subscribed flags. if (this._mode === 'subscribe') { this._selectedIds = new Set( this._interests.filter(i => i.subscribed).map(i => i.id) ); } this._render(); } catch (err) { this._renderError(err.message); } } /** Returns the currently selected/subscribed interest IDs (select mode). */ getSelectedIds() { return [...this._selectedIds]; } /** Programmatically set selected IDs (select mode). Re-renders. */ setSelectedIds(ids) { this._selectedIds = new Set(ids); this._render(); } // ------------------------------------------------------------------------- // Rendering // ------------------------------------------------------------------------- _renderLoading() { this._el.innerHTML = '<div class="text-center text-muted py-5">' + '<i class="fas fa-spinner fa-spin fa-2x mb-2"></i><br>' + (window.t ? window.t('ui.loading', {}, 'Loading?') : 'Loading?') + '</div>'; } _renderError(msg) { this._el.innerHTML = '<div class="alert alert-danger">' + '<i class="fas fa-exclamation-triangle me-2"></i>' + InterestPicker._escHtml(msg) + '</div>'; } _render() { if (!this._interests.length) { const msg = window.t ? window.t('ui.interests.no_interests', {}, 'No interests are subscribed to.') : 'No interests are subscribed to.'; this._el.innerHTML = '<div class="alert alert-info">' + InterestPicker._escHtml(msg) + '</div>'; return; } const isSelect = this._mode === 'select'; const cards = this._interests.map(interest => { const subscribed = this._selectedIds.has(interest.id); const colorStyle = `border-top: 3px solid ${InterestPicker._escAttr(interest.color || '#6c757d')}`; const iconStyle = `color: ${InterestPicker._escAttr(interest.color || '#6c757d')}`; const echoCount = window.t ? window.t('ui.interests.area_count', { count: interest.echoarea_count ?? 0 }, '{count} echo area(s)') : `${interest.echoarea_count ?? 0} echo area(s)`; const echoCountHtml = (interest.echoarea_count ?? 0) > 0 ? `<a href="#" class="text-muted interest-areas-link" data-interest-id="${interest.id}" data-interest-name="${InterestPicker._escAttr(interest.name)}" onclick="InterestPicker._handleAreasClick(this); return false;"><i class="fas fa-comments me-1"></i>${InterestPicker._escHtml(echoCount)}</a>` : `<span><i class="fas fa-comments me-1"></i>${InterestPicker._escHtml(echoCount)}</span>`; if (isSelect) { const checked = subscribed ? 'checked' : ''; return `<div class="col"> <div class="card h-100 interest-card ${subscribed ? 'interest-selected' : ''}" style="${colorStyle}" data-id="${interest.id}"> <div class="card-body d-flex flex-column"> <div class="d-flex align-items-center gap-2 mb-2"> <i class="fas ${InterestPicker._escHtml(interest.icon || 'fa-layer-group')} fa-lg flex-shrink-0" style="${iconStyle}"></i> <h6 class="card-title mb-0 fw-semibold">${InterestPicker._escHtml(interest.name)}</h6> <div class="ms-auto form-check mb-0"> <input class="form-check-input interest-select-check" type="checkbox" id="ipick_${interest.id}" value="${interest.id}" ${checked} onchange="InterestPicker._handleSelectChange(this)"> </div> </div> ${interest.description ? `<p class="card-text small text-muted flex-grow-1">${InterestPicker._escHtml(interest.description)}</p>` : '<div class="flex-grow-1"></div>'} <div class="d-flex gap-2 mt-2 small text-muted"> ${echoCountHtml} </div> </div> </div> </div>`; } // Subscribe mode const busy = this._busy.has(interest.id); const btnIcon = busy ? 'fa-spinner fa-spin' : (subscribed ? 'fa-check' : 'fa-plus'); const btnClass = subscribed ? 'btn-success' : 'btn-outline-secondary'; const btnLabel = subscribed ? (window.t ? window.t('ui.interests.subscribed', {}, 'Subscribed') : 'Subscribed') : (window.t ? window.t('ui.interests.subscribe', {}, 'Subscribe') : 'Subscribe'); return `<div class="col"> <div class="card h-100 interest-card" style="${colorStyle}" data-id="${interest.id}"> <div class="card-body d-flex flex-column"> <div class="d-flex align-items-center gap-2 mb-2"> <i class="fas ${InterestPicker._escHtml(interest.icon || 'fa-layer-group')} fa-lg flex-shrink-0" style="${iconStyle}"></i> <h6 class="card-title mb-0 fw-semibold">${InterestPicker._escHtml(interest.name)}</h6> </div> ${interest.description ? `<p class="card-text small text-muted flex-grow-1">${InterestPicker._escHtml(interest.description)}</p>` : '<div class="flex-grow-1"></div>'} <div class="d-flex justify-content-between align-items-center mt-2"> <div class="small text-muted me-2"> <span class="me-2">${echoCountHtml}</span> </div> <button class="btn btn-sm ${btnClass} interest-sub-btn" data-id="${interest.id}" data-subscribed="${subscribed ? '1' : '0'}" ${busy ? 'disabled' : ''} onclick="InterestPicker._handleSubClick(this)"> <i class="fas ${btnIcon} me-1"></i>${InterestPicker._escHtml(btnLabel)} </button> </div> </div> </div> </div>`; }).join(''); this._el.innerHTML = `<div class="row row-cols-1 row-cols-sm-2 row-cols-lg-3 g-3">${cards}</div>`; // Store picker reference on element so static callbacks can find it. this._el.dataset.ipickerInstance = InterestPicker._register(this); } // ------------------------------------------------------------------------- // Subscribe-mode toggle // ------------------------------------------------------------------------- async _toggleSubscription(id, currentlySubscribed) { if (this._busy.has(id)) return; this._busy.add(id); this._refreshCard(id); const action = currentlySubscribed ? 'unsubscribe' : 'subscribe'; try { const resp = await fetch(`/api/interests/${id}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); if (!resp.ok) throw new Error('HTTP ' + resp.status); if (currentlySubscribed) { this._selectedIds.delete(id); } else { this._selectedIds.add(id); } } catch (err) { // Revert on error ? interest stays in its previous state. console.error('[InterestPicker] toggle failed:', err); } finally { this._busy.delete(id); this._refreshCard(id); } } /** Re-render just the button / card for one interest ID. */ _refreshCard(id) { const interest = this._interests.find(i => i.id === id); if (!interest) return; const btn = this._el.querySelector(`.interest-sub-btn[data-id="${id}"]`); if (!btn) return; const subscribed = this._selectedIds.has(id); const busy = this._busy.has(id); btn.className = `btn btn-sm ${subscribed ? 'btn-success' : 'btn-outline-secondary'} interest-sub-btn`; btn.disabled = busy; btn.dataset.subscribed = subscribed ? '1' : '0'; const label = subscribed ? (window.t ? window.t('ui.interests.subscribed', {}, 'Subscribed') : 'Subscribed') : (window.t ? window.t('ui.interests.subscribe', {}, 'Subscribe') : 'Subscribe'); btn.innerHTML = `<i class="fas ${busy ? 'fa-spinner fa-spin' : (subscribed ? 'fa-check' : 'fa-plus')} me-1"></i>${InterestPicker._escHtml(label)}`; } // ------------------------------------------------------------------------- // Select-mode toggle // ------------------------------------------------------------------------- _handleSelect(checkbox) { const id = parseInt(checkbox.value); if (checkbox.checked) { this._selectedIds.add(id); } else { this._selectedIds.delete(id); } // Highlight the card. const card = this._el.querySelector(`.interest-card[data-id="${id}"]`); if (card) card.classList.toggle('interest-selected', checkbox.checked); if (this._onSelectionChange) { this._onSelectionChange(this.getSelectedIds()); } } // ------------------------------------------------------------------------- // Static helpers (used by inline onclick handlers) // ------------------------------------------------------------------------- static _instances = {}; static _nextId = 0; static _register(instance) { const key = 'ip_' + (InterestPicker._nextId++); InterestPicker._instances[key] = instance; return key; } static _handleSubClick(btn) { const container = btn.closest('[data-ipicker-instance]'); if (!container) return; const instance = InterestPicker._instances[container.dataset.ipickerInstance]; if (!instance) return; instance._showAreasModal(parseInt(btn.dataset.id)); } static _handleSelectChange(checkbox) { const container = checkbox.closest('[data-ipicker-instance]'); if (!container) return; const instance = InterestPicker._instances[container.dataset.ipickerInstance]; if (!instance) return; instance._handleSelect(checkbox); } static _handleAreasClick(link) { const container = link.closest('[data-ipicker-instance]'); if (!container) return; const instance = InterestPicker._instances[container.dataset.ipickerInstance]; if (!instance) return; instance._showAreasModal(parseInt(link.dataset.interestId), link.dataset.interestName); } async _showAreasModal(interestId) { const interest = this._interests.find(i => i.id === interestId); const name = interest ? interest.name : ''; const isSubscribed = interest ? this._selectedIds.has(interestId) : false; const isSubMode = this._mode === 'subscribe'; // When already subscribed use 'manage' mode so the user can adjust individual areas. const operation = (isSubMode && isSubscribed) ? 'manage' : 'subscribe'; // Create modal shell once, reuse it. let modalEl = document.getElementById('interestAreasModal'); if (!modalEl) { modalEl = document.createElement('div'); modalEl.id = 'interestAreasModal'; modalEl.className = 'modal fade'; modalEl.tabIndex = -1; modalEl.innerHTML = ` <div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="interestAreasModalTitle"></h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body" id="interestAreasModalBody"></div> <div class="modal-footer d-none" id="interestAreasModalFooter"></div> </div> </div>`; document.body.appendChild(modalEl); } const titleEl = document.getElementById('interestAreasModalTitle'); const bodyEl = document.getElementById('interestAreasModalBody'); const footerEl = document.getElementById('interestAreasModalFooter'); // Set title based on mode and operation. if (isSubMode) { let key, fallback; if (operation === 'manage') { key = 'ui.interests.areas_modal_manage_title'; fallback = `Manage Areas ? ${name}`; } else { key = 'ui.interests.areas_modal_subscribe_title'; fallback = `Subscribe to ${name}`; } titleEl.textContent = window.t ? window.t(key, { name }, fallback) : fallback; } else { const fallback = `Echo Areas ? ${name}`; titleEl.textContent = window.t ? window.t('ui.interests.areas_modal_title', { name }, fallback) : fallback; } bodyEl.innerHTML = '<div class="text-center text-muted py-4"><i class="fas fa-spinner fa-spin fa-2x"></i></div>'; footerEl.innerHTML = ''; footerEl.classList.add('d-none'); const modal = bootstrap.Modal.getOrCreateInstance(modalEl); modal.show(); try { const resp = await fetch(`/api/interests/${interestId}/echoareas`); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); const areas = data.echoareas || []; if (!areas.length) { bodyEl.innerHTML = '<p class="text-muted mb-0">' + InterestPicker._escHtml( window.t ? window.t('ui.interests.areas_modal_empty', {}, 'No echo areas assigned.') : 'No echo areas assigned.' ) + '</p>'; return; } const thTag = window.t ? window.t('ui.interests.areas_modal_col_tag', {}, 'Echo Area') : 'Echo Area'; const thNet = window.t ? window.t('ui.interests.areas_modal_col_net', {}, 'Network') : 'Network'; const thDesc = window.t ? window.t('ui.interests.areas_modal_col_desc', {}, 'Description') : 'Description'; const subBadge = `<span class="badge bg-success ms-1" title="${InterestPicker._escAttr(window.t ? window.t('ui.interests.already_subscribed', {}, 'Already subscribed') : 'Already subscribed')}"><i class="fas fa-check"></i></span>`; if (!isSubMode) { // Read-only table for select mode. bodyEl.innerHTML = `<table class="table table-sm table-hover mb-0"> <thead><tr> <th>${InterestPicker._escHtml(thTag)}</th> <th>${InterestPicker._escHtml(thNet)}</th> <th>${InterestPicker._escHtml(thDesc)}</th> </tr></thead> <tbody>${areas.map(a => `<tr> <td class="fw-semibold text-warning">${InterestPicker._escHtml(a.tag)}${a.subscribed ? subBadge : ''}</td> <td><span class="badge bg-secondary fw-normal">${InterestPicker._escHtml(a.domain || '')}</span></td> <td>${InterestPicker._escHtml(a.description || '')}</td> </tr>`).join('')}</tbody> </table>`; return; } // Subscribe/unsubscribe mode ? render checkboxes. const selectAllLabel = window.t ? window.t('ui.interests.areas_modal_select_all', {}, 'Select all') : 'Select all'; const deselectAllLabel = window.t ? window.t('ui.interests.areas_modal_deselect_all', {}, 'Deselect all') : 'Deselect all'; bodyEl.innerHTML = `<div class="d-flex gap-3 mb-2 small"> <a href="#" id="ipickSelectAll">${InterestPicker._escHtml(selectAllLabel)}</a> <a href="#" id="ipickDeselectAll">${InterestPicker._escHtml(deselectAllLabel)}</a> </div> <table class="table table-sm table-hover mb-0"> <thead><tr> <th style="width:2.5rem"></th> <th>${InterestPicker._escHtml(thTag)}</th> <th>${InterestPicker._escHtml(thNet)}</th> <th>${InterestPicker._escHtml(thDesc)}</th> </tr></thead> <tbody>${areas.map(a => { const checked = operation === 'manage' ? (a.subscribed ? 'checked' : '') : 'checked'; return `<tr> <td><input class="form-check-input interest-area-check" type="checkbox" value="${InterestPicker._escAttr(String(a.echoarea_id))}" ${checked}></td> <td class="fw-semibold text-warning">${InterestPicker._escHtml(a.tag)}${a.subscribed ? subBadge : ''}</td> <td><span class="badge bg-secondary fw-normal">${InterestPicker._escHtml(a.domain || '')}</span></td> <td>${InterestPicker._escHtml(a.description || '')}</td> </tr>`; }).join('')}</tbody> </table>`; const cancelLabel = window.t ? window.t('ui.cancel', {}, 'Cancel') : 'Cancel'; footerEl.innerHTML = `<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${InterestPicker._escHtml(cancelLabel)}</button> <button type="button" class="btn btn-primary" id="interestAreasConfirmBtn" disabled></button>`; footerEl.classList.remove('d-none'); const confirmBtn = document.getElementById('interestAreasConfirmBtn'); const updateBtn = () => { const count = bodyEl.querySelectorAll('.interest-area-check:checked').length; if (operation === 'manage') { if (count === 0) { const label = window.t ? window.t('ui.interests.unsubscribe', {}, 'Unsubscribe') : 'Unsubscribe'; confirmBtn.textContent = label; confirmBtn.className = 'btn btn-danger'; } else { const key = 'ui.interests.areas_modal_save_btn'; const fallback = `Save ${count} area(s)`; confirmBtn.textContent = window.t ? window.t(key, { count }, fallback) : fallback; confirmBtn.className = 'btn btn-primary'; } confirmBtn.disabled = false; } else { const key = 'ui.interests.areas_modal_confirm_subscribe'; const fallback = `Subscribe to ${count} area(s)`; confirmBtn.textContent = window.t ? window.t(key, { count }, fallback) : fallback; confirmBtn.className = 'btn btn-primary'; confirmBtn.disabled = count === 0; } }; bodyEl.querySelectorAll('.interest-area-check').forEach(cb => cb.addEventListener('change', updateBtn)); document.getElementById('ipickSelectAll').addEventListener('click', e => { e.preventDefault(); bodyEl.querySelectorAll('.interest-area-check').forEach(cb => cb.checked = true); updateBtn(); }); document.getElementById('ipickDeselectAll').addEventListener('click', e => { e.preventDefault(); bodyEl.querySelectorAll('.interest-area-check').forEach(cb => cb.checked = false); updateBtn(); }); updateBtn(); confirmBtn.addEventListener('click', async () => { const ids = [...bodyEl.querySelectorAll('.interest-area-check:checked')].map(cb => parseInt(cb.value)); if (operation !== 'manage' && !ids.length) return; confirmBtn.disabled = true; confirmBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; try { let apiPath, bodyPayload; if (operation === 'manage') { apiPath = 'manage-areas'; bodyPayload = JSON.stringify({ wanted_echoarea_ids: ids }); } else { apiPath = 'subscribe'; bodyPayload = JSON.stringify({ echoarea_ids: ids }); } const resp = await fetch(`/api/interests/${interestId}/${apiPath}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: bodyPayload, }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const result = await resp.json(); if (result.subscribed) { this._selectedIds.add(interestId); } else { this._selectedIds.delete(interestId); } this._refreshCard(interestId); modal.hide(); } catch (err) { console.error('[InterestPicker] confirm failed:', err); confirmBtn.disabled = false; updateBtn(); } }); } catch (err) { bodyEl.innerHTML = '<div class="alert alert-danger mb-0">' + InterestPicker._escHtml(err.message) + '</div>'; } } static _escHtml(str) { return String(str) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;'); } static _escAttr(str) { return String(str).replace(/"/g, '&quot;').replace(/'/g, '&#39;'); } }