assets/js/main.js
/* ==========================================================================
Dashboard auto-refresh
========================================================================== */
/*
|--------------------------------------------------------------------------
| Automatic refresh
|--------------------------------------------------------------------------
|
| Refresh the dashboard at the configured interval while preserving
| the current view. Refreshes are suspended whenever a modal dialog
| is open to avoid interrupting user interactions.
|
*/
(() => {
const seconds = Number(window.BRIVACIA_AUTO_REFRESH_SECONDS || 0);
if (seconds <= 0) {
return;
}
function hasOpenModal() {
return Boolean(document.querySelector('dialog[open], dialog.modal[open], .modal[open], .modal.is-open') || document.body.dataset.updating === '1');
}
window.setInterval(() => {
if (hasOpenModal()) {
return;
}
window.location.reload();
}, seconds * 1000);
})();
/* ==========================================================================
Date picker
========================================================================== */
/*
|--------------------------------------------------------------------------
| Native date picker support
|--------------------------------------------------------------------------
|
| iOS handles date-related input types differently from most browsers.
| This section restores the expected picker behaviour and keeps the
| dashboard date selector consistent across platforms.
|
*/
const isIOS =
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (isIOS) {
document.documentElement.classList.add('ios');
const periodInput = document.getElementById('period');
if (periodInput?.dataset.iosType) {
periodInput.type = periodInput.dataset.iosType;
periodInput.value = periodInput.dataset.iosValue ?? periodInput.value;
}
}
const dateButton = document.querySelector('button[data-period-type]');
const dateInput = document.querySelector('.date-form input:not([type="hidden"])');
dateButton?.addEventListener('click', () => {
dateInput?.showPicker?.();
});
/* ==========================================================================
Dropdowns
========================================================================== */
/*
|--------------------------------------------------------------------------
| Shared dropdown behaviour
|--------------------------------------------------------------------------
|
| Used by export, import and future dropdown menus. Only one menu can
| remain open at a time and all menus close automatically when the
| user clicks elsewhere or presses Escape.
|
*/
const dropdowns = document.querySelectorAll('[data-dropdown]');
function closeMenus(selector = '[data-dropdown-menu], [data-dropdown-submenu]') {
document.querySelectorAll(selector).forEach((menu) => {
menu.hidden = true;
});
}
dropdowns.forEach((dropdown) => {
const toggle = dropdown.querySelector(':scope > [data-dropdown-toggle]');
const menu = dropdown.querySelector(':scope > [data-dropdown-menu], :scope > [data-dropdown-submenu]');
if (!toggle || !menu) {
return;
}
toggle.addEventListener('click', (event) => {
event.stopPropagation();
const isSubmenu = menu.hasAttribute('data-dropdown-submenu');
const willOpen = menu.hidden;
if (isSubmenu) {
dropdown.parentElement
?.querySelectorAll(':scope > [data-dropdown] > [data-dropdown-submenu]')
.forEach((submenu) => {
if (submenu !== menu) {
submenu.hidden = true;
}
});
} else {
closeMenus();
}
menu.hidden = !willOpen;
});
});
document.addEventListener('click', () => closeMenus());
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeMenus();
}
});
/* ==========================================================================
Internationalization
========================================================================== */
/*
|--------------------------------------------------------------------------
| Locale-aware formatting
|--------------------------------------------------------------------------
|
| Use the visitor's preferred browser language for dates, times and
| country names so the dashboard feels native without requiring
| additional translations for generated values.
|
*/
const lang = navigator.language;
const dateFormatter = new Intl.DateTimeFormat(lang, {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
const timeFormatter = new Intl.DateTimeFormat(lang, {
hour: 'numeric',
minute: '2-digit'
});
const countryNames = new Intl.DisplayNames([lang], { type: 'region' });
function formatPeriodLabel(el, now = new Date()) {
const type = el.dataset.periodType;
const value = el.dataset.periodValue;
if (type === 'today') {
return dateFormatter.format(now);
}
if (type === 'week') {
const week = value.split('-W')[1] ?? value;
return `${el.dataset.weekLabel ?? 'Week'} ${week}`;
}
if (type === 'month') {
const text = new Intl.DateTimeFormat(lang, {
year: 'numeric',
month: 'long'
}).format(new Date(value + '-01T00:00:00'));
return text.charAt(0).toUpperCase() + text.slice(1);
}
if (type === 'year') {
return value.slice(0, 4);
}
if (type === 'all') {
return el.dataset.allLabel ?? el.textContent.trim();
}
return el.textContent.trim();
}
function updateDateTime() {
const now = new Date();
const currentDate = dateFormatter.format(now);
const currentTime = timeFormatter.format(now);
for (const el of document.querySelectorAll('[data-live-date]')) {
el.textContent = currentDate;
}
for (const el of document.querySelectorAll('[data-current-time]')) {
el.textContent = currentTime;
}
}
updateDateTime();
setInterval(updateDateTime, 1000);
for (const el of document.querySelectorAll('span[data-country]')) {
if (el.dataset.country === 'XX') {
continue;
}
el.textContent =
countryNames.of(el.dataset.country) ??
el.dataset.country;
}
/* ==========================================================================
Modals
========================================================================== */
/*
|--------------------------------------------------------------------------
| Modal helpers
|--------------------------------------------------------------------------
|
| Keep modal behaviour centralized:
| - open dialogs
| - close dialogs when clicking outside their content
| - remove focus from fields when a modal is opened or closed
|
*/
function openModal(selector) {
document
.querySelector(selector)
?.closest('.modal')
?.showModal();
}
function blurModalFields(modal) {
modal
?.querySelectorAll('input, select, textarea, button')
?.forEach((element) => element.blur());
}
function closeModal(event) {
if (!event.target.classList.contains('modal')) {
return;
}
blurModalFields(event.target);
event.target.close();
}
/*
|--------------------------------------------------------------------------
| Modal initialization
|--------------------------------------------------------------------------
|
| Attach close handlers to all dialogs present in the page.
|
*/
for (const modal of document.querySelectorAll('.modal')) {
modal.addEventListener('click', closeModal);
modal.addEventListener('close', () => {
blurModalFields(modal);
document.body.style.overflow = '';
});
}
/*
|--------------------------------------------------------------------------
| Lazy-loaded import providers
|--------------------------------------------------------------------------
|
| Import tools are rarely used during normal dashboard navigation.
| Load the import providers script only when an import modal is opened,
| exactly like Prism is loaded only when the code modal is opened.
|
*/
let importsLoaded = false;
async function loadImports(modal = document.getElementById('import-modal')) {
if (!importsLoaded) {
importsLoaded = true;
try {
await loadScript('/assets/js/imports.min.js');
} catch (error) {
importsLoaded = false;
throw error;
}
}
window.brivaciaSetImportProvider?.(
modal?.dataset.importProvider || 'matomo'
);
}
/*
|--------------------------------------------------------------------------
| Modal triggers
|--------------------------------------------------------------------------
|
| Buttons use data-modal-open="<modal-id>".
| The matching dialog is opened directly from the DOM.
|
*/
for (const button of document.querySelectorAll('[data-modal-open]')) {
button.addEventListener('click', async () => {
const modal = document.getElementById(button.dataset.modalOpen);
if (!modal) {
return;
}
if (button.dataset.importProvider) {
modal.dataset.importProvider = button.dataset.importProvider;
}
modal.showModal();
if (modal.hasAttribute('data-import')) {
loadImports(modal);
}
if (modal.hasAttribute('data-prism')) {
loadPrism();
}
/*
|--------------------------------------------------------------------------
| Mobile usability
|--------------------------------------------------------------------------
|
| Prevent mobile browsers from focusing the first input
| and automatically opening the virtual keyboard.
|
*/
requestAnimationFrame(() => {
blurModalFields(modal);
modal.scrollTo?.(0, 0);
modal.querySelector(':scope > div')?.scrollTo?.(0, 0);
});
});
}
/* ==========================================================================
Settings
========================================================================== */
(() => {
const form = document.getElementById('settings-form');
const sites = document.getElementById('settings-sites');
const template = document.getElementById('settings-site-template');
const message = document.getElementById('settings-message');
const settingsText = {
saved: message?.dataset.saved ?? 'Settings saved.',
error: message?.dataset.error ?? 'Unable to save settings.',
networkError: message?.dataset.networkError ?? 'Network error.'
};
if (!form) {
return;
}
function setMessage(text, status = '') {
if (!message) {
return;
}
message.className = status;
message.textContent = text || '';
}
function closeSettingsModal() {
form.closest('dialog')?.close();
}
function siteList() {
return sites?.querySelector(':scope > div') || null;
}
function reindexSites() {
const list = siteList();
if (!list) return;
[...list.children].forEach((fieldset, index) => {
const code = fieldset.querySelector('input[name$="[code]"]');
const domain = fieldset.querySelector('input[name$="[domain]"]');
if (code) code.name = `sites[${index}][code]`;
if (domain) domain.name = `sites[${index}][domain]`;
});
}
function addSite() {
const list = siteList();
if (!list || !template) return;
const index = list.children.length;
const wrapper = document.createElement('div');
wrapper.innerHTML = template.innerHTML
.replaceAll('__INDEX__', String(index))
.trim();
if (wrapper.firstElementChild) {
list.appendChild(wrapper.firstElementChild);
}
}
function removeSite(button) {
const list = siteList();
const fieldset = button.closest('fieldset');
if (!list || !fieldset || list.children.length <= 1) return;
fieldset.remove();
reindexSites();
}
function formToObject() {
const data = new FormData(form);
const result = {};
for (const [name, value] of data.entries()) {
if (!name.startsWith('sites[')) {
result[name] = value;
}
}
form.querySelectorAll('input[type="checkbox"]').forEach((input) => {
result[input.name] = input.checked;
});
result.sites = [];
siteList()?.querySelectorAll(':scope > fieldset').forEach((fieldset) => {
const code = fieldset.querySelector('input[name$="[code]"]')?.value.trim() || '';
const domain = fieldset.querySelector('input[name$="[domain]"]')?.value.trim() || '';
if (code !== '' || domain !== '') {
result.sites.push({ code, domain });
}
});
return result;
}
form.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
if (button.value === 'cancel') {
closeSettingsModal();
return;
}
if (button.value === 'add-site') {
addSite();
return;
}
if (button.value === 'remove-site') {
removeSite(button);
}
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
setMessage('');
try {
const response = await fetch('/api/settings.php?action=save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formToObject()),
});
const data = await response.json();
if (!response.ok || !data.ok) {
setMessage(data.error || settingsText.error, 'danger');
return;
}
setMessage(settingsText.saved, 'success');
setTimeout(() => {
closeSettingsModal();
window.location.reload();
}, 600);
} catch {
setMessage(settingsText.networkError, 'warning');
}
});
})();
/* ==========================================================================
Graphs
========================================================================== */
const graph = document.querySelector('.graph');
const graphText = {
error: graph?.dataset.error ?? 'Unable to generate the chart',
generating: graph?.dataset.generating ?? 'Generating chart...',
noData: graph?.dataset.noData ?? 'No data available'
};
function graphModal() {
return document.querySelector('dialog.modal:has(.graph)');
}
function resetGraphModal() {
const modal = graphModal();
if (!modal) {
return;
}
modal.id = 'graph-modal';
modal.removeAttribute('data-graph');
}
async function loadGraph(metric) {
if (!graph) {
return;
}
graph.textContent = graphText.generating;
try {
const params = new URLSearchParams(window.location.search);
const lang = navigator.language || document.documentElement.lang || 'en';
params.set('metric', metric);
params.set('lang', lang);
const response = await fetch(`/api/graphs.php?${params.toString()}`);
if (!response.ok) {
const message = (await response.text()).trim();
graph.textContent = message !== ''
? message
: graphText.error;
return;
}
const html = await response.text();
graph.innerHTML = html.trim() !== ''
? html
: graphText.noData;
} catch {
graph.textContent = graphText.error;
}
}
graphModal()?.addEventListener('close', resetGraphModal);
for (const button of document.querySelectorAll('[data-graph]')) {
button.addEventListener('click', async () => {
const metric = button.dataset.graph;
const modal = document.getElementById('graph-modal');
if (modal && metric) {
modal.id = metric;
modal.dataset.graph = metric;
}
openModal('.graph');
await loadGraph(metric);
});
}
/* ==========================================================================
Graph: Interactive map
========================================================================== */
let mapScale = 1;
let mapTranslateX = 0;
let mapTranslateY = 0;
let mapTouchDistance = null;
let mapDragging = false;
let mapDragStartX = 0;
let mapDragStartY = 0;
function applyMapTransform(map) {
map.querySelector('svg').style.transform =
`translate(${mapTranslateX}px, ${mapTranslateY}px) scale(${mapScale})`;
}
function setMapScale(map, scale) {
mapScale = Math.max(1, Math.min(scale, 8));
if (mapScale === 1) {
mapTranslateX = 0;
mapTranslateY = 0;
}
applyMapTransform(map);
}
function touchDistance(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.hypot(dx, dy);
}
/*
|--------------------------------------------------------------------------
| Zoom origin
|--------------------------------------------------------------------------
|
| Update the SVG transform origin so zooming occurs around the cursor
| (or pinch center on touch devices) instead of always zooming from
| the middle of the map.
|
*/
function setMapOriginFromPoint(map, clientX, clientY) {
const svg = map.querySelector('svg');
const rect = svg.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * 100;
const y = ((clientY - rect.top) / rect.height) * 100;
svg.style.transformOrigin = `${x}% ${y}%`;
}
document.addEventListener('wheel', (event) => {
const map = event.target.closest('.graph-map');
if (!map) {
return;
}
event.preventDefault();
setMapOriginFromPoint(map, event.clientX, event.clientY);
setMapScale(
map,
mapScale * (event.deltaY > 0 ? 0.9 : 1.1)
);
}, { passive: false });
document.addEventListener('pointerdown', (event) => {
const map = event.target.closest('.graph-map');
if (!map || mapScale <= 1 || event.pointerType === 'touch') {
return;
}
mapDragging = true;
mapDragStartX = event.clientX - mapTranslateX;
mapDragStartY = event.clientY - mapTranslateY;
});
document.addEventListener('pointermove', (event) => {
const map = event.target.closest('.graph-map');
if (!map || !mapDragging) {
return;
}
mapTranslateX = event.clientX - mapDragStartX;
mapTranslateY = event.clientY - mapDragStartY;
applyMapTransform(map);
});
document.addEventListener('pointerup', () => {
mapDragging = false;
});
document.addEventListener('touchstart', (event) => {
const map = event.target.closest('.graph-map');
if (!map) {
return;
}
if (event.touches.length === 2) {
event.preventDefault();
mapTouchDistance = touchDistance(event.touches);
return;
}
if (event.touches.length === 1 && mapScale > 1) {
event.preventDefault();
mapDragging = true;
mapDragStartX = event.touches[0].clientX - mapTranslateX;
mapDragStartY = event.touches[0].clientY - mapTranslateY;
}
}, { passive: false });
document.addEventListener('touchmove', (event) => {
const map = event.target.closest('.graph-map');
if (!map) {
return;
}
if (event.touches.length === 2 && mapTouchDistance !== null) {
event.preventDefault();
const distance = touchDistance(event.touches);
const ratio = distance / mapTouchDistance;
const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
setMapOriginFromPoint(map, centerX, centerY);
setMapScale(map, mapScale * ratio);
mapTouchDistance = distance;
return;
}
if (event.touches.length === 1 && mapDragging && mapScale > 1) {
event.preventDefault();
mapTranslateX = event.touches[0].clientX - mapDragStartX;
mapTranslateY = event.touches[0].clientY - mapDragStartY;
applyMapTransform(map);
}
}, { passive: false });
document.addEventListener('touchend', () => {
mapTouchDistance = null;
mapDragging = false;
});
/* ==========================================================================
Tooltips
========================================================================== */
/*
|--------------------------------------------------------------------------
| Dynamic tooltips
|--------------------------------------------------------------------------
|
| A single tooltip system is shared across the dashboard. Tooltips are
| created on demand and automatically positioned to remain visible
| inside the viewport or modal dialog.
|
*/
let tooltipElement = null;
let tooltipTarget = null;
function tooltipTargetFrom(event) {
let element = event.target;
while (element && element !== document) {
if (element.dataset?.tooltip) {
return element;
}
element = element.parentNode;
}
return null;
}
document.addEventListener('pointerover', (event) => {
const element = tooltipTargetFrom(event);
if (!element || tooltipTarget === element) {
return;
}
openTooltip(element, event);
});
document.addEventListener('pointerout', (event) => {
const element = tooltipTargetFrom(event);
if (!element) {
return;
}
if (event.relatedTarget && element.contains(event.relatedTarget)) {
return;
}
closeTooltip();
});
document.addEventListener('focusin', (event) => {
const element = tooltipTargetFrom(event);
if (element) {
openTooltip(element);
}
});
document.addEventListener('focusout', (event) => {
if (tooltipTargetFrom(event)) {
closeTooltip();
}
});
document.addEventListener('pointerdown', (event) => {
if (!tooltipTargetFrom(event)) {
closeTooltip();
}
});
function openTooltip(element, event = null) {
closeTooltip();
tooltipTarget = element;
tooltipElement = document.createElement('div');
tooltipElement.setAttribute('data-tooltip-open', '');
if (element.dataset.tooltipClass) {
tooltipElement.classList.add(element.dataset.tooltipClass);
}
if (element.dataset.tooltipIcon) {
tooltipElement.innerHTML =
`<img alt="" class="tooltip-icon" src="${element.dataset.tooltipIcon}">` +
`<span>${element.dataset.tooltip ?? ''}</span>`;
} else {
tooltipElement.textContent = element.dataset.tooltip ?? '';
}
(element.closest('dialog') ?? document.body).appendChild(tooltipElement);
const margin = parseFloat(getComputedStyle(document.documentElement).fontSize);
const gap = margin;
const rect = element.getBoundingClientRect();
const tooltipRect = tooltipElement.getBoundingClientRect();
if (element.dataset.tooltipClass === 'graph-tooltip' && event && !element.dataset.tooltipPlacement) {
const containerRect = (
element.closest('dialog') ??
document.body
).getBoundingClientRect();
let left = event.clientX - (tooltipRect.width / 2) - containerRect.left;
let top = event.clientY + gap - containerRect.top;
if (event.clientY + gap + tooltipRect.height > window.innerHeight - margin) {
top = event.clientY - tooltipRect.height - gap - containerRect.top;
}
left = Math.max(
margin,
Math.min(left, containerRect.width - tooltipRect.width - margin)
);
tooltipElement.style.setProperty('--tooltip-left', `${left}px`);
tooltipElement.style.setProperty('--tooltip-top', `${top}px`);
tooltipElement.setAttribute('data-tooltip-placement', 'graph');
requestAnimationFrame(() => {
tooltipElement?.setAttribute('data-tooltip-visible', '');
});
return;
}
let left;
let top;
let placement = element.dataset.tooltipPlacement ?? 'bottom';
if (placement === 'left' && window.matchMedia('(hover: none)').matches) {
placement = 'top';
}
if (placement === 'left') {
left = rect.left - tooltipRect.width - gap;
top = rect.top + (rect.height / 2) - (tooltipRect.height / 2);
} else if (placement === 'right') {
left = rect.right + gap;
top = rect.top + (rect.height / 2) - (tooltipRect.height / 2);
} else {
left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
top = rect.bottom + gap;
if (top + tooltipRect.height > window.innerHeight - margin) {
top = rect.top - tooltipRect.height - gap;
placement = 'top';
}
}
left = Math.max(margin, Math.min(left, window.innerWidth - tooltipRect.width - margin));
top = Math.max(margin, Math.min(top, window.innerHeight - tooltipRect.height - margin));
tooltipElement.setAttribute('data-tooltip-placement', placement);
tooltipElement.style.setProperty('--tooltip-left', `${left}px`);
tooltipElement.style.setProperty('--tooltip-top', `${top}px`);
requestAnimationFrame(() => {
tooltipElement?.setAttribute('data-tooltip-visible', '');
});
}
function closeTooltip() {
if (!tooltipElement) {
return;
}
const currentTooltip = tooltipElement;
currentTooltip.removeAttribute('data-tooltip-visible');
setTimeout(() => {
currentTooltip.remove();
}, 120);
tooltipElement = null;
tooltipTarget = null;
}
/* ==========================================================================
Prism
========================================================================== */
/*
|--------------------------------------------------------------------------
| Deferred syntax highlighting
|--------------------------------------------------------------------------
|
| Prism is loaded only when the integration code modal is opened.
| This avoids downloading the highlighting library for visitors who
| never view code snippets.
|
*/
let prismLoaded = false;
async function loadPrism() {
if (prismLoaded) {
return;
}
prismLoaded = true;
await Promise.all([
loadScript('/assets/js/prism.min.js')
]);
Prism.highlightAllUnder(
document.getElementById('pixel-modal')
);
}
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
/* ==========================================================================
Privacy transparency
========================================================================== */
/*
|--------------------------------------------------------------------------
| Transparency modal
|--------------------------------------------------------------------------
|
| Display runtime information that may be useful to privacy-conscious
| visitors, such as the browser language detected by Brivacia.
|
*/
const privacyButton = document.querySelector('[data-privacy-open]');
const browserLanguage = document.querySelector('[data-browser-language]');
privacyButton?.addEventListener('click', () => {
if (browserLanguage) {
browserLanguage.textContent = navigator.language || 'no data';
}
openModal('.privacy');
});
/* ==========================================================================
Update
========================================================================== */
(() => {
const button = document.getElementById('update-install');
if (!button) return;
const originalHtml = button.innerHTML;
const available = document.getElementById('update-available');
const progress = document.getElementById('update-progress');
const progressText = document.getElementById('update-progress-text');
const labels = {
download: button.dataset.updateDownload,
extract: button.dataset.updateExtract,
verify: button.dataset.updateVerify,
install: button.dataset.updateInstalling,
cleanup: button.dataset.updateCleanup,
};
let timer = null;
const pollProgress = async () => {
try {
const response = await fetch('/api/update.php?progress=1', { cache: 'no-store' });
const data = await response.json();
if (data.running && data.step && labels[data.step]) {
progressText.textContent = labels[data.step];
}
} catch {}
};
button.addEventListener('click', async event => {
event.preventDefault();
event.stopImmediatePropagation();
console.log('Brivacia update clicked');
if (button.disabled) {
return;
}
button.disabled = true;
document.body.dataset.updating = '1';
available.hidden = true;
progress.hidden = false;
progressText.textContent = button.dataset.updateStarting;
timer = setInterval(pollProgress, 400);
try {
console.log('Brivacia update POST start');
const response = await fetch('/api/update.php', {
method: 'POST',
cache: 'no-store',
});
console.log('Brivacia update POST response', response.status);
const text = await response.text();
console.log('Brivacia update raw response', text);
const data = JSON.parse(text);
if (!response.ok || !data.ok) {
throw new Error(data.error || button.dataset.updateFailed);
}
clearInterval(timer);
progressText.textContent = button.dataset.updateReload;
await new Promise(resolve => setTimeout(resolve, 1200));
window.location.reload();
} catch (error) {
console.error('Brivacia update failed', error);
clearInterval(timer);
delete document.body.dataset.updating;
button.disabled = false;
available.hidden = false;
progress.hidden = true;
button.innerHTML = originalHtml;
alert(error.message || button.dataset.updateFailed);
}
});
})();