mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-23 14:40:36 +01:00
* Theme Switch: Refactor and add system option Before, we had no way to reset the theme option to the system default. This introduces that option and refactors the theme switch to work in a simpler manner. * Prevent account menu close on click inside Context: #5476
517 lines
20 KiB
JavaScript
517 lines
20 KiB
JavaScript
const baseUrl = Object.values(document.scripts).find(s => s.src.includes('/main/site.js')).src.split('/main/site.js').shift();
|
|
|
|
const flatpickrInstances = [];
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
|
|
const dtFormatOpts = { dateStyle: 'short', timeStyle: 'short' };
|
|
|
|
const formatDateTimes = format => {
|
|
// select only elements which haven't been initialized before, those without data-localized
|
|
document.querySelectorAll("time[datetime]:not([data-localized])").forEach($el => {
|
|
const date = new Date($el.getAttribute("datetime"));
|
|
// initialize and set localized attribute
|
|
$el.dataset.localized = new Intl.DateTimeFormat('default', dtFormatOpts).format(date);
|
|
// set text to chosen mode
|
|
const mode = format || $el.dataset.initial;
|
|
if ($el.dataset[mode]) $el.innerText = $el.dataset[mode];
|
|
});
|
|
};
|
|
|
|
const switchTimeFormat = event => {
|
|
const curr = event.target.dataset.mode || 'localized';
|
|
const mode = curr === 'relative' ? 'localized' : 'relative';
|
|
document.querySelectorAll("time[datetime]").forEach($el => {
|
|
$el.innerText = $el.dataset[mode];
|
|
});
|
|
event.target.dataset.mode = mode;
|
|
};
|
|
|
|
async function initLabelManager (elementId) {
|
|
const element = document.getElementById(elementId);
|
|
|
|
const labelStyle = data =>
|
|
data && data.color && data.textColor
|
|
? `--label-bg:${data.color};--label-fg:${data.textColor}`
|
|
: '--label-bg:var(--btcpay-neutral-300);--label-fg:var(--btcpay-neutral-800)'
|
|
|
|
if (element) {
|
|
const { fetchUrl, updateUrl, walletId, walletObjectType, walletObjectId, labels, selectElement } = element.dataset;
|
|
const commonCallId = `walletLabels-${walletId}`;
|
|
if (!window[commonCallId]) {
|
|
window[commonCallId] = fetch(fetchUrl, {
|
|
method: 'GET',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
}).then(res => res.json());
|
|
}
|
|
const items = element.value.split(',').filter(x => !!x);
|
|
const options = await window[commonCallId].then(labels => {
|
|
const newItems = items.filter(item => !labels.find(label => label.label === item));
|
|
labels = [...labels, ...newItems.map(item => ({ label: item }))];
|
|
return labels;
|
|
});
|
|
const richInfo = labels ? JSON.parse(labels) : {};
|
|
const config = {
|
|
options,
|
|
items,
|
|
valueField: "label",
|
|
labelField: "label",
|
|
searchField: "label",
|
|
create: true,
|
|
persist: true,
|
|
allowEmptyOption: false,
|
|
closeAfterSelect: false,
|
|
render: {
|
|
dropdown (){
|
|
return '<div class="dropdown-menu"></div>';
|
|
},
|
|
option_create: function(data, escape) {
|
|
return `<div class="transaction-label create" style="${labelStyle(null)}">Add <strong>${escape(data.input)}</strong>…</div>`;
|
|
},
|
|
option (data, escape) {
|
|
return `<div class="transaction-label" style="${labelStyle(data)}"><span>${escape(data.label)}</span></div>`;
|
|
},
|
|
item (data, escape) {
|
|
const info = richInfo && richInfo[data.label];
|
|
const additionalInfo = info
|
|
? `<a href="${info.link}" target="_blank" rel="noreferrer noopener" class="transaction-label-info transaction-details-icon" title="${info.tooltip}" data-bs-html="true"
|
|
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip"><svg role="img" class="icon icon-info"><use href="/img/icon-sprite.svg#info"></use></svg></a>`
|
|
: '';
|
|
const inner = `<span>${escape(data.label)}</span>${additionalInfo}`;
|
|
return `<div class="transaction-label" style="${labelStyle(data)}">${inner}</div>`;
|
|
}
|
|
},
|
|
onItemAdd (val) {
|
|
window[commonCallId] = window[commonCallId].then(labels => {
|
|
return [...labels, { label: val }]
|
|
});
|
|
|
|
document.dispatchEvent(new CustomEvent(`${commonCallId}-option-added`, {
|
|
detail: val
|
|
}));
|
|
},
|
|
async onChange (values) {
|
|
const selectElementI = selectElement ? document.getElementById(selectElement) : null;
|
|
if (selectElementI){
|
|
while (selectElementI.options.length > 0) {
|
|
selectElementI.remove(0);
|
|
}
|
|
select.items.forEach((item) => {
|
|
selectElementI.add(new Option(item, item, true, true));
|
|
})
|
|
}
|
|
if(!updateUrl)
|
|
return;
|
|
select.lock();
|
|
try {
|
|
const response = await fetch(updateUrl, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
id: walletObjectId,
|
|
type: walletObjectType,
|
|
labels: select.items
|
|
})
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Network response was not OK');
|
|
}
|
|
} catch (error) {
|
|
console.error('There has been a problem with your fetch operation:', error);
|
|
} finally {
|
|
select.unlock();
|
|
}
|
|
}
|
|
};
|
|
const select = new TomSelect(element, config);
|
|
|
|
element.parentElement.querySelectorAll('.ts-control .transaction-label a').forEach(lbl => {
|
|
lbl.addEventListener('click', e => {
|
|
e.stopPropagation()
|
|
})
|
|
})
|
|
|
|
document.addEventListener(`${commonCallId}-option-added`, evt => {
|
|
if (!(evt.detail in select.options)) {
|
|
select.addOption({
|
|
label: evt.detail
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
const initLabelManagers = () => {
|
|
// select only elements which haven't been initialized before, those without data-localized
|
|
document.querySelectorAll("input.label-manager:not(.tomselected)").forEach($el => {
|
|
initLabelManager($el.id)
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
// sticky header
|
|
const stickyHeader = document.querySelector('#mainContent > section .sticky-header');
|
|
if (stickyHeader) {
|
|
const setStickyHeaderHeight = () => {
|
|
document.documentElement.style.setProperty('--sticky-header-height', `${stickyHeader.offsetHeight}px`)
|
|
}
|
|
window.addEventListener('resize', e => {
|
|
debounce('resize', setStickyHeaderHeight, 50)
|
|
});
|
|
setStickyHeaderHeight();
|
|
}
|
|
|
|
// initialize timezone offset value if field is present in page
|
|
var timezoneOffset = new Date().getTimezoneOffset();
|
|
$("#TimezoneOffset").val(timezoneOffset);
|
|
|
|
// localize all elements that have localizeDate class
|
|
formatDateTimes();
|
|
|
|
initLabelManagers();
|
|
|
|
function updateTimeAgo(){
|
|
var timeagoElements = $("[data-timeago-unixms]");
|
|
timeagoElements.each(function () {
|
|
var elem = $(this);
|
|
elem.text(moment(elem.data("timeago-unixms")).fromNow());
|
|
});
|
|
setTimeout(updateTimeAgo, 1000);
|
|
}
|
|
updateTimeAgo();
|
|
|
|
// intializing date time pickers
|
|
$(".flatdtpicker").each(function () {
|
|
var element = $(this);
|
|
var fdtp = element.attr("data-fdtp");
|
|
|
|
// support for initializing with special options per instance
|
|
if (fdtp) {
|
|
var parsed = JSON.parse(fdtp);
|
|
flatpickrInstances.push(element.flatpickr(parsed));
|
|
} else {
|
|
var min = element.attr("min");
|
|
var max = element.attr("max");
|
|
var defaultDate = element.attr("value");
|
|
flatpickrInstances.push(element.flatpickr({
|
|
enableTime: true,
|
|
enableSeconds: true,
|
|
dateFormat: 'Z',
|
|
altInput: true,
|
|
altFormat: 'Y-m-d H:i:S',
|
|
minDate: min,
|
|
maxDate: max,
|
|
defaultDate: defaultDate,
|
|
time_24hr: true,
|
|
defaultHour: 0,
|
|
static: true
|
|
}));
|
|
}
|
|
});
|
|
// rich text editor
|
|
if ($.summernote) {
|
|
$('.richtext').summernote({
|
|
minHeight: 300,
|
|
tableClassName: 'table table-sm',
|
|
insertTableMaxSize: {
|
|
col: 5,
|
|
row: 10
|
|
},
|
|
codeviewFilter: true,
|
|
codeviewFilterRegex: new RegExp($.summernote.options.codeviewFilterRegex.source + '|<.*?( on\\w+?=.*?)>', 'gi'),
|
|
codeviewIframeWhitelistSrc: ['twitter.com', 'syndication.twitter.com']
|
|
});
|
|
}
|
|
|
|
$(".input-group-clear").on("click", function () {
|
|
const input = $(this).parents(".input-group").find("input");
|
|
const event = new CustomEvent('input-group-clear-input-value-cleared', { detail: input });
|
|
input.val(null);
|
|
document.dispatchEvent(event);
|
|
handleInputGroupClearButtonDisplay(this);
|
|
});
|
|
|
|
$(".input-group-clear").each(function () {
|
|
var inputGroupClearBtn = this;
|
|
handleInputGroupClearButtonDisplay(inputGroupClearBtn);
|
|
$(this).parents(".input-group").find("input").on("change input", function () {
|
|
handleInputGroupClearButtonDisplay(inputGroupClearBtn);
|
|
});
|
|
});
|
|
|
|
$('[data-bs-toggle="tooltip"]').tooltip();
|
|
|
|
function handleInputGroupClearButtonDisplay(element) {
|
|
var inputs = $(element).parents(".input-group").find("input");
|
|
|
|
$(element).hide();
|
|
for (var i = 0; i < inputs.length; i++) {
|
|
var el = inputs.get(i);
|
|
if ($(el).val() || el.attributes.value) {
|
|
$(element).show();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$('[data-toggle="password"]').each(function () {
|
|
var input = $(this);
|
|
var eye_btn = $(this).parent().find('.input-group-text');
|
|
eye_btn.css('cursor', 'pointer').addClass('input-password-hide');
|
|
eye_btn.on('click', function () {
|
|
if (eye_btn.hasClass('input-password-hide')) {
|
|
eye_btn.removeClass('input-password-hide').addClass('input-password-show');
|
|
eye_btn.find('.fa').removeClass('fa-eye').addClass('fa-eye-slash')
|
|
input.attr('type', 'text');
|
|
} else {
|
|
eye_btn.removeClass('input-password-show').addClass('input-password-hide');
|
|
eye_btn.find('.fa').removeClass('fa-eye-slash').addClass('fa-eye')
|
|
input.attr('type', 'password');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Invoice Status
|
|
delegate('click', '[data-invoice-state-badge] [data-invoice-id][data-new-state]', async e => {
|
|
const $button = e.target
|
|
const $badge = $button.closest('[data-invoice-state-badge]')
|
|
const { invoiceId, newState } = $button.dataset
|
|
|
|
$badge.classList.add('pe-none'); // disable further interaction
|
|
const response = await fetch(`${baseUrl}/invoices/${invoiceId}/changestate/${newState}`, { method: 'POST' })
|
|
if (response.ok) {
|
|
const { statusString } = await response.json()
|
|
$badge.outerHTML = `<div class="badge badge-${newState}" data-invoice-state-badge="${invoiceId}">${statusString}</div>`
|
|
} else {
|
|
$badge.classList.remove('pe-none');
|
|
alert("Invoice state update failed");
|
|
}
|
|
})
|
|
|
|
// Time Format
|
|
delegate('click', '.switch-time-format', switchTimeFormat);
|
|
|
|
// Theme Switch
|
|
delegate('click', '.btcpay-theme-switch [data-theme]', e => {
|
|
e.preventDefault()
|
|
const $btn = e.target.closest('.btcpay-theme-switch [data-theme]')
|
|
setColorMode($btn.dataset.theme)
|
|
$btn.blur()
|
|
})
|
|
|
|
// Sensitive Info
|
|
const SENSITIVE_INFO_STORE_KEY = 'btcpay-hide-sensitive-info';
|
|
const SENSITIVE_INFO_DATA_ATTR = 'data-hide-sensitive-info';
|
|
delegate('change', '#HideSensitiveInfo', e => {
|
|
e.preventDefault()
|
|
const isActive = window.localStorage.getItem(SENSITIVE_INFO_STORE_KEY) === 'true';
|
|
if (isActive) {
|
|
window.localStorage.removeItem(SENSITIVE_INFO_STORE_KEY);
|
|
document.documentElement.removeAttribute(SENSITIVE_INFO_DATA_ATTR);
|
|
} else {
|
|
window.localStorage.setItem(SENSITIVE_INFO_STORE_KEY, 'true');
|
|
document.documentElement.setAttribute(SENSITIVE_INFO_DATA_ATTR, 'true');
|
|
}
|
|
});
|
|
|
|
// Currency Selection: Remove the current input value once the element is focused, so that the user gets to
|
|
// see the available options. If no selection or change is made, reset it to the previous value on blur.
|
|
// Note: Use focusin/focusout instead of focus/blur, because the latter do not bubble up and delegate won't work.
|
|
delegate('focusin', 'input[list="currency-selection-suggestion"]', e => {
|
|
e.target.setAttribute('placeholder', e.target.value)
|
|
e.target.value = '';
|
|
})
|
|
delegate('focusout', 'input[list="currency-selection-suggestion"]', e => {
|
|
if (!e.target.value) e.target.value = e.target.getAttribute('placeholder')
|
|
e.target.removeAttribute('placeholder')
|
|
})
|
|
|
|
// Offcanvas navigation
|
|
const mainMenuToggle = document.getElementById('mainMenuToggle')
|
|
if (mainMenuToggle) {
|
|
delegate('show.bs.offcanvas', '#mainNav', () => {
|
|
mainMenuToggle.setAttribute('aria-expanded', 'true')
|
|
})
|
|
delegate('hide.bs.offcanvas', '#mainNav', () => {
|
|
mainMenuToggle.setAttribute('aria-expanded', 'false')
|
|
})
|
|
}
|
|
|
|
// Menu collapses
|
|
const mainNav = document.getElementById('mainNav')
|
|
if (mainNav) {
|
|
const COLLAPSED_KEY = 'btcpay-nav-collapsed'
|
|
delegate('show.bs.collapse', '#mainNav', (e) => {
|
|
const { id } = e.target
|
|
const navCollapsed = window.localStorage.getItem(COLLAPSED_KEY)
|
|
const collapsed = navCollapsed ? JSON.parse(navCollapsed).filter(i => i !== id ) : []
|
|
window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify(collapsed))
|
|
})
|
|
delegate('hide.bs.collapse', '#mainNav', (e) => {
|
|
const { id } = e.target
|
|
const navCollapsed = window.localStorage.getItem(COLLAPSED_KEY)
|
|
const collapsed = navCollapsed ? JSON.parse(navCollapsed) : []
|
|
if (!collapsed.includes(id)) collapsed.push(id)
|
|
window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify(collapsed))
|
|
})
|
|
}
|
|
|
|
// Mass Action Tables
|
|
const updateSelectedCount = ($table) => {
|
|
const selectedCount = document.querySelectorAll('.mass-action-select:checked').length;
|
|
const $selectedCount = $table.querySelector('.mass-action-selected-count');
|
|
if ($selectedCount) $selectedCount.innerText = selectedCount;
|
|
if (selectedCount === 0) {
|
|
$table.removeAttribute('data-selected');
|
|
} else {
|
|
$table.setAttribute('data-selected', selectedCount.toString());
|
|
}
|
|
}
|
|
|
|
delegate('click', '.mass-action .mass-action-select-all', e => {
|
|
const $table = e.target.closest('.mass-action');
|
|
const { checked } = e.target;
|
|
$table.querySelectorAll('.mass-action-select,.mass-action-select-all').forEach($checkbox => {
|
|
$checkbox.checked = checked;
|
|
});
|
|
updateSelectedCount($table);
|
|
});
|
|
|
|
delegate('change', '.mass-action .mass-action-select', e => {
|
|
const $table = e.target.closest('.mass-action');
|
|
const selectedCount = $table.querySelectorAll('.mass-action-select:checked').length;
|
|
if (selectedCount === 0) {
|
|
$table.querySelectorAll('.mass-action-select-all').forEach(checkbox => {
|
|
checkbox.checked = false;
|
|
});
|
|
}
|
|
updateSelectedCount($table);
|
|
});
|
|
|
|
delegate('click', '.mass-action .mass-action-row', e => {
|
|
const $target = e.target
|
|
if ($target.matches('td,time,span[data-sensitive]')) {
|
|
const $row = $target.closest('.mass-action-row');
|
|
$row.querySelector('.mass-action-select').click();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Initialize Blazor
|
|
if (window.Blazor) {
|
|
let isUnloading = false;
|
|
window.addEventListener("beforeunload", () => { isUnloading = true; });
|
|
let brokenConnection = {
|
|
isConnected: false,
|
|
titleContent: 'Connection broken',
|
|
innerHTML: 'Please <a href="">refresh the page</a>.'
|
|
};
|
|
let interruptedConnection = {
|
|
isConnected: false,
|
|
titleContent: 'Connection interrupted',
|
|
innerHTML: 'Attempt to reestablish the connection in a few seconds...'
|
|
};
|
|
let successfulConnection = {
|
|
isConnected: true,
|
|
titleContent: 'Connection established',
|
|
innerHTML: '' // use empty link on purpose
|
|
};
|
|
class BlazorReconnectionHandler {
|
|
reconnecting = false;
|
|
async onConnectionDown(options, _error) {
|
|
if (this.reconnecting)
|
|
return;
|
|
this.setBlazorStatus(interruptedConnection);
|
|
this.reconnecting = true;
|
|
console.debug('Blazor hub connection lost');
|
|
await this.reconnect();
|
|
}
|
|
|
|
async reconnect() {
|
|
let delays = [500, 1000, 2000, 4000, 8000, 16000, 20000, 40000];
|
|
let i = 0;
|
|
const lastDelay = delays.length - 1;
|
|
while (i < delays.length) {
|
|
await this.delay(delays[i]);
|
|
try {
|
|
if (await Blazor.reconnect())
|
|
return;
|
|
console.warn('Error while reconnecting to Blazor hub (Broken circuit)');
|
|
break;
|
|
}
|
|
catch (err) {
|
|
this.setBlazorStatus(interruptedConnection);
|
|
console.warn(`Error while reconnecting to Blazor hub (${err})`);
|
|
}
|
|
i++;
|
|
}
|
|
this.setBlazorStatus(brokenConnection);
|
|
}
|
|
onConnectionUp() {
|
|
this.reconnecting = false;
|
|
console.debug('Blazor hub connected');
|
|
this.setBlazorStatus(successfulConnection);
|
|
}
|
|
|
|
setBlazorStatus(content) {
|
|
document.querySelectorAll('.blazor-status').forEach($status => {
|
|
const $state = $status.querySelector('.blazor-status__state');
|
|
const $title = $status.querySelector('.blazor-status__title');
|
|
const $body = $status.querySelector('.blazor-status__body');
|
|
$state.classList.remove('btcpay-status--enabled');
|
|
$state.classList.remove('btcpay-status--disabled');
|
|
$state.classList.add(content.isConnected ? 'btcpay-status--enabled' : 'btcpay-status--disabled');
|
|
$title.textContent = content.titleContent;
|
|
$body.innerHTML = content.innerHTML;
|
|
$body.classList.toggle('d-none', content.isConnected);
|
|
if (!isUnloading) {
|
|
const toast = new bootstrap.Toast($status, { autohide: false });
|
|
if (content.isConnected) {
|
|
if (toast.isShown())
|
|
toast.hide();
|
|
}
|
|
else {
|
|
if (!toast.isShown())
|
|
toast.show();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
delay(durationMilliseconds) {
|
|
return new Promise(resolve => setTimeout(resolve, durationMilliseconds));
|
|
}
|
|
}
|
|
|
|
const handler = new BlazorReconnectionHandler();
|
|
handler.setBlazorStatus(successfulConnection);
|
|
Blazor.start({
|
|
reconnectionHandler: handler
|
|
});
|
|
}
|
|
|
|
String.prototype.noExponents= function(){
|
|
const data = String(this).split(/[eE]/);
|
|
if(data.length== 1) return data[0];
|
|
|
|
var z= '', sign= this<0? '-':'',
|
|
str= data[0].replace('.', ''),
|
|
mag= Number(data[1])+ 1;
|
|
|
|
if(mag<0){
|
|
z= sign + '0.';
|
|
while(mag++) z += '0';
|
|
return z + str.replace(/^\-/,'');
|
|
}
|
|
mag -= str.length;
|
|
while(mag--) z += '0';
|
|
return str + z;
|
|
}
|
|
|
|
Number.prototype.noExponents= function(){
|
|
return String(this).noExponents();
|
|
};
|