- Replace filter pills with btn-group segmented controls on mobile (all pages) - Add renderMobileSegmented() shared utility with colored count badges - Compact sync card and logs run selector on mobile - Unified flat-row format: dot + date + name + count (0.875rem throughout) - Responsive navbar with short labels on mobile (Acasa/Mapari/Lipsa/Jurnale) - Vertical dots icon (bi-three-dots-vertical) without dropdown caret - Shorter "Mapare" button text on mobile, Re-scan in context menu - Top pagination on logs page, hide per-page selector on mobile - Cache-bust static assets to v=5 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
215 lines
7.9 KiB
JavaScript
215 lines
7.9 KiB
JavaScript
// shared.js - Unified utilities for all pages
|
|
|
|
// ── HTML escaping ─────────────────────────────────
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.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 = '<div class="d-flex align-items-center gap-2 flex-wrap">';
|
|
|
|
// Per-page selector
|
|
if (opts && opts.perPage && opts.perPageFn) {
|
|
const options = opts.perPageOptions || [25, 50, 100, 250];
|
|
html += `<label class="per-page-label">Per pagina: <select class="select-compact ms-1" onchange="${opts.perPageFn}(this.value)">`;
|
|
options.forEach(v => {
|
|
html += `<option value="${v}"${v === opts.perPage ? ' selected' : ''}>${v}</option>`;
|
|
});
|
|
html += '</select></label>';
|
|
}
|
|
|
|
if (totalPages <= 1) {
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
html += '<div class="pagination-bar">';
|
|
|
|
// First
|
|
html += `<button class="page-btn" onclick="${goToFnName}(1)" ${currentPage <= 1 ? 'disabled' : ''}>«</button>`;
|
|
// Prev
|
|
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>‹</button>`;
|
|
|
|
// 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 += `<span class="page-btn disabled page-ellipsis">…</span>`;
|
|
}
|
|
html += `<button class="page-btn page-number${p === currentPage ? ' active' : ''}" onclick="${goToFnName}(${p})">${p}</button>`;
|
|
lastP = p;
|
|
});
|
|
|
|
// Next
|
|
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>›</button>`;
|
|
// Last
|
|
html += `<button class="page-btn" onclick="${goToFnName}(${totalPages})" ${currentPage >= totalPages ? 'disabled' : ''}>»</button>`;
|
|
|
|
html += '</div></div>';
|
|
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 = `<div class="btn-group btn-group-sm w-100">${pills.map(p => {
|
|
const cls = p.active ? 'btn btn-primary' : 'btn btn-outline-secondary';
|
|
const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : '';
|
|
return `<button type="button" class="${cls}" style="${btnStyle}" data-seg-value="${esc(p.value)}">${esc(p.label)} <b${countColor}>${p.count}</b></button>`;
|
|
}).join('')}</div>`;
|
|
|
|
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 '<span class="dot dot-green"></span>';
|
|
case 'SKIPPED':
|
|
case 'UNRESOLVED':
|
|
case 'INCOMPLETE':
|
|
return '<span class="dot dot-yellow"></span>';
|
|
case 'ERROR':
|
|
case 'FAILED':
|
|
return '<span class="dot dot-red"></span>';
|
|
default:
|
|
return '<span class="dot dot-gray"></span>';
|
|
}
|
|
}
|