// shared.js - Unified utilities for all pages // ── Root path patch — prepend ROOT_PATH to all relative fetch calls ─────── (function() { const _fetch = window.fetch.bind(window); window.fetch = function(url, ...args) { if (typeof url === 'string' && url.startsWith('/') && window.ROOT_PATH) { url = window.ROOT_PATH + url; } return _fetch(url, ...args); }; })(); // ── HTML escaping ───────────────────────────────── function esc(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ── Date formatting ─────────────────────────────── function fmtDate(dateStr, includeSeconds) { if (!dateStr) return '-'; try { const d = new Date(dateStr); const hasTime = dateStr.includes(':'); if (hasTime) { const opts = { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }; if (includeSeconds) opts.second = '2-digit'; return d.toLocaleString('ro-RO', opts); } return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' }); } catch { return dateStr; } } // ── Unified Pagination ──────────────────────────── /** * Renders a full pagination bar with First/Prev/numbers/Next/Last. * @param {number} currentPage * @param {number} totalPages * @param {string} goToFnName - name of global function to call with page number * @param {object} [opts] - optional: { perPage, perPageFn, perPageOptions } * @returns {string} HTML string */ function renderUnifiedPagination(currentPage, totalPages, goToFnName, opts) { if (totalPages <= 1 && !(opts && opts.perPage)) { return ''; } let html = '
'; // Per-page selector if (opts && opts.perPage && opts.perPageFn) { const options = opts.perPageOptions || [25, 50, 100, 250]; html += `'; } if (totalPages <= 1) { html += '
'; return html; } html += '
'; // First html += ``; // Prev html += ``; // Page numbers with ellipsis const range = 2; let pages = []; for (let i = 1; i <= totalPages; i++) { if (i === 1 || i === totalPages || (i >= currentPage - range && i <= currentPage + range)) { pages.push(i); } } let lastP = 0; pages.forEach(p => { if (lastP && p - lastP > 1) { html += ``; } html += ``; lastP = p; }); // Next html += ``; // Last html += ``; html += '
'; return html; } // ── Context Menu ────────────────────────────────── let _activeContextMenu = null; function closeAllContextMenus() { if (_activeContextMenu) { _activeContextMenu.remove(); _activeContextMenu = null; } } document.addEventListener('click', closeAllContextMenus); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAllContextMenus(); }); /** * Show a context menu at the given position. * @param {number} x - clientX * @param {number} y - clientY * @param {Array} items - [{label, action, danger}] */ function showContextMenu(x, y, items) { closeAllContextMenus(); const menu = document.createElement('div'); menu.className = 'context-menu'; items.forEach(item => { const btn = document.createElement('button'); btn.className = 'context-menu-item' + (item.danger ? ' text-danger' : ''); btn.textContent = item.label; btn.addEventListener('click', (e) => { e.stopPropagation(); closeAllContextMenus(); item.action(); }); menu.appendChild(btn); }); document.body.appendChild(menu); _activeContextMenu = menu; // Position menu, keeping it within viewport const rect = menu.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let left = x; let top = y; if (left + 160 > vw) left = vw - 165; if (top + rect.height > vh) top = vh - rect.height - 5; menu.style.left = left + 'px'; menu.style.top = top + 'px'; } /** * Wire right-click on desktop + three-dots button on mobile for a table. * @param {string} rowSelector - CSS selector for clickable rows * @param {function} menuItemsFn - called with row element, returns [{label, action, danger}] */ function initContextMenus(rowSelector, menuItemsFn) { document.addEventListener('contextmenu', (e) => { const row = e.target.closest(rowSelector); if (!row) return; e.preventDefault(); showContextMenu(e.clientX, e.clientY, menuItemsFn(row)); }); document.addEventListener('click', (e) => { const trigger = e.target.closest('.context-menu-trigger'); if (!trigger) return; const row = trigger.closest(rowSelector); if (!row) return; e.stopPropagation(); const rect = trigger.getBoundingClientRect(); showContextMenu(rect.left, rect.bottom + 2, menuItemsFn(row)); }); } // ── Mobile segmented control ───────────────────── /** * Render a Bootstrap btn-group segmented control for mobile. * @param {string} containerId - ID of the container div * @param {Array} pills - [{label, count, colorClass, value, active}] * @param {function} onSelect - callback(value) */ function renderMobileSegmented(containerId, pills, onSelect) { const container = document.getElementById(containerId); if (!container) return; const btnStyle = 'font-size:0.75rem;height:32px;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;flex:1;padding:0 0.25rem'; container.innerHTML = `
${pills.map(p => { const cls = p.active ? 'btn btn-primary' : 'btn btn-outline-secondary'; const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : ''; return ``; }).join('')}
`; container.querySelectorAll('[data-seg-value]').forEach(btn => { btn.addEventListener('click', () => onSelect(btn.dataset.segValue)); }); } // ── Dot helper ──────────────────────────────────── function statusDot(status) { switch ((status || '').toUpperCase()) { case 'IMPORTED': case 'ALREADY_IMPORTED': case 'COMPLETED': case 'RESOLVED': return ''; case 'SKIPPED': case 'UNRESOLVED': case 'INCOMPLETE': return ''; case 'ERROR': case 'FAILED': return ''; default: return ''; } }