You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
638 lines
20 KiB
638 lines
20 KiB
// site-app.js
|
|
// Copyright (C) 2021 Digital Telepresence, LLC
|
|
// License: Apache-2.0
|
|
|
|
'use strict';
|
|
|
|
const DTP_COMPONENT_NAME = 'SiteApp';
|
|
const dtp = window.dtp = window.dtp || { };
|
|
|
|
import DtpApp from 'dtp/dtp-app.js';
|
|
|
|
import UIkit from 'uikit';
|
|
import QRCode from 'qrcode';
|
|
import Cropper from 'cropperjs';
|
|
|
|
import { EmojiButton } from '@joeattardi/emoji-button';
|
|
|
|
const GRID_COLOR = '#a0a0a0';
|
|
const GRID_TICK_COLOR = '#707070';
|
|
|
|
const AXIS_TICK_COLOR = '#c0c0c0';
|
|
|
|
const CHART_LINE_USER = 'rgb(0, 192, 0)';
|
|
|
|
export default class DtpSiteApp extends DtpApp {
|
|
|
|
constructor (user) {
|
|
super(DTP_COMPONENT_NAME, user);
|
|
this.log.debug('constructor', 'app instance created');
|
|
|
|
this.chat = {
|
|
form: document.querySelector('#chat-input-form'),
|
|
messageList: document.querySelector('#chat-message-list'),
|
|
messages: [ ],
|
|
messageMenu: document.querySelector('.chat-message-menu'),
|
|
input: document.querySelector('#chat-input-text'),
|
|
isAtBottom: true,
|
|
};
|
|
|
|
this.emojiPicker = new EmojiButton({ theme: 'dark' });
|
|
this.emojiPicker.on('emoji', this.onEmojiSelected.bind(this));
|
|
|
|
if (this.chat.messageList) {
|
|
this.chat.messageList.addEventListener('scroll', this.onChatMessageListScroll.bind(this));
|
|
}
|
|
if (this.chat.input) {
|
|
this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this));
|
|
}
|
|
|
|
this.charts = {/* will hold rendered charts */};
|
|
}
|
|
|
|
async connect ( ) {
|
|
// can't use "super" because Webpack
|
|
this.log.info('connect', 'connecting WebSocket layer');
|
|
await DtpApp.prototype.connect.call(this, { withRetry: true, withError: false });
|
|
if (this.user) {
|
|
const { socket } = this.socket;
|
|
socket.on('user-chat', this.onUserChat.bind(this));
|
|
}
|
|
}
|
|
|
|
async onChatInputKeyDown (event) {
|
|
this.log.info('onChatInputKeyDown', 'chat input received', { event });
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
return this.sendUserChat(event);
|
|
}
|
|
}
|
|
|
|
async sendUserChat (event) {
|
|
event.preventDefault();
|
|
|
|
if (!dtp.channel || !dtp.channel._id) {
|
|
UIkit.modal.alert('There is a problem with Chat. Please refresh the page.');
|
|
return;
|
|
}
|
|
|
|
const channelId = dtp.channel._id;
|
|
this.log.info('chat form', channelId);
|
|
|
|
const content = this.chat.input.value;
|
|
this.chat.input.value = '';
|
|
|
|
if (content.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
this.log.info('sendUserChat', 'sending chat message', { channel: this.user._id, content });
|
|
this.socket.sendUserChat(channelId, content);
|
|
|
|
// set focus back to chat input
|
|
this.chat.input.focus();
|
|
|
|
return true;
|
|
}
|
|
|
|
async onUserChat (message) {
|
|
this.log.info('onUserChat', 'message received', { user: message.user, content: message.content });
|
|
|
|
const chatMessage = document.createElement('div');
|
|
chatMessage.classList.add('uk-margin-small');
|
|
chatMessage.classList.add('chat-message');
|
|
|
|
const chatUser = document.createElement('div');
|
|
chatUser.classList.add('uk-text-small');
|
|
chatUser.classList.add('chat-username');
|
|
chatUser.textContent = message.user.username;
|
|
chatMessage.appendChild(chatUser);
|
|
|
|
const chatContent = document.createElement('div');
|
|
chatContent.classList.add('chat-content');
|
|
chatContent.innerHTML = message.content;
|
|
chatMessage.appendChild(chatContent);
|
|
|
|
if (Array.isArray(message.stickers) && message.stickers.length) {
|
|
message.stickers.forEach((sticker) => {
|
|
const chatContent = document.createElement('div');
|
|
chatContent.classList.add('chat-sticker');
|
|
chatContent.innerHTML = `<video playsinline autoplay muted loop><source src="/sticker/${sticker}.mp4"></source></video>`;
|
|
chatMessage.appendChild(chatContent);
|
|
});
|
|
}
|
|
|
|
this.chat.messageList.appendChild(chatMessage);
|
|
this.chat.messages.push(chatMessage);
|
|
|
|
while (this.chat.messages.length > 50) {
|
|
const message = this.chat.messages.shift();
|
|
this.chat.messageList.removeChild(message);
|
|
}
|
|
if (this.chat.isAtBottom) {
|
|
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight);
|
|
}
|
|
}
|
|
|
|
async onChatMessageListScroll (/* event */) {
|
|
const prevBottom = this.chat.isAtBottom;
|
|
const scrollPos = this.chat.messageList.scrollTop + this.chat.messageList.clientHeight;
|
|
|
|
this.chat.isAtBottom = scrollPos >= this.chat.messageList.scrollHeight;
|
|
if (this.chat.isAtBottom !== prevBottom) {
|
|
this.log.info('onChatMessageListScroll', 'at-bottom status change', { atBottom: this.chat.isAtBottom });
|
|
if (this.chat.isAtBottom) {
|
|
this.chat.messageMenu.classList.remove('chat-menu-visible');
|
|
} else {
|
|
this.chat.messageMenu.classList.add('chat-menu-visible');
|
|
}
|
|
}
|
|
}
|
|
|
|
async resumeChatScroll ( ) {
|
|
this.chat.messageList.scrollTop = this.chat.messageList.scrollHeight;
|
|
}
|
|
|
|
async goBack ( ) {
|
|
if (document.referrer && (document.referrer.indexOf(`://${window.dtp.domain}`) >= 0)) {
|
|
window.history.back();
|
|
} else {
|
|
window.location.href= '/';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async submitForm (event, userAction) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
try {
|
|
const formElement = event.currentTarget || event.target;
|
|
const form = new FormData(formElement);
|
|
|
|
this.log.info('submitForm', userAction, { event, action: formElement.action });
|
|
const response = await fetch(formElement.action, {
|
|
method: formElement.method,
|
|
body: form,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let json;
|
|
try {
|
|
json = await response.json();
|
|
} catch (error) {
|
|
throw new Error('Server error');
|
|
}
|
|
throw new Error(json.message || 'Server error');
|
|
}
|
|
|
|
await this.processResponse(response);
|
|
} catch (error) {
|
|
UIkit.modal.alert(`Failed to ${userAction}: ${error.message}`);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
async copyHtmlToText (event, textContentId) {
|
|
const content = this.editor.getContent({ format: 'text' });
|
|
const text = document.getElementById(textContentId);
|
|
text.value = content;
|
|
}
|
|
|
|
async selectImageFile (event) {
|
|
event.preventDefault();
|
|
|
|
const imageId = event.target.getAttribute('data-image-id');
|
|
|
|
//z read the cropper options from the element on the page
|
|
const cropperOptions = event.target.getAttribute('data-cropper-options');
|
|
this.log.debug('selectImageFile', 'cropper options', { cropperOptions }); //z remove when done
|
|
|
|
const fileSelectContainerId = event.target.getAttribute('data-file-select-container');
|
|
if (!fileSelectContainerId) {
|
|
UIkit.modal.alert('Missing file select container element ID information');
|
|
return;
|
|
}
|
|
|
|
const fileSelectContainer = document.getElementById(fileSelectContainerId);
|
|
if (!fileSelectContainer) {
|
|
UIkit.modal.alert('Missing file select element');
|
|
return;
|
|
}
|
|
|
|
const fileSelect = fileSelectContainer.querySelector('input[type="file"]');
|
|
if (!fileSelect.files || (fileSelect.files.length === 0)) {
|
|
return;
|
|
}
|
|
|
|
const selectedFile = fileSelect.files[0];
|
|
if (!selectedFile) {
|
|
return;
|
|
}
|
|
|
|
this.log.debug('selectImageFile', 'thumbnail file select', { event, selectedFile });
|
|
|
|
const filter = /^(image\/jpg|image\/jpeg|image\/png)$/i;
|
|
if (!filter.test(selectedFile.type)) {
|
|
UIkit.modal.alert(`Unsupported image file type selected: ${selectedFile.type}`);
|
|
return;
|
|
}
|
|
|
|
const fileSizeId = event.target.getAttribute('data-file-size-element');
|
|
const FILE_MAX_SIZE = parseInt(fileSelect.getAttribute('data-file-max-size'), 10);
|
|
const fileSize = document.getElementById(fileSizeId);
|
|
fileSize.textContent = numeral(selectedFile.size).format('0,0.0b');
|
|
if (selectedFile.size > (FILE_MAX_SIZE)) {
|
|
UIkit.modal.alert(`File is too large: ${fileSize.textContent}. Custom thumbnail images may be up to ${numeral(FILE_MAX_SIZE).format('0,0.00b')} in size.`);
|
|
return;
|
|
}
|
|
|
|
// const IMAGE_WIDTH = parseInt(event.target.getAttribute('data-image-w'));
|
|
// const IMAGE_HEIGHT = parseInt(event.target.getAttribute('data-image-h'));
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const img = document.getElementById(imageId);
|
|
img.onload = (e) => {
|
|
console.log('image loaded', e, img.naturalWidth, img.naturalHeight);
|
|
// if (img.naturalWidth !== IMAGE_WIDTH || img.naturalHeight !== IMAGE_HEIGHT) {
|
|
// UIkit.modal.alert(`This image must be ${IMAGE_WIDTH}x${IMAGE_HEIGHT}`);
|
|
// img.setAttribute('hidden', '');
|
|
// img.src = '';
|
|
// return;
|
|
// }
|
|
|
|
fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name;
|
|
fileSelectContainer.querySelector('#file-modified').textContent = moment(selectedFile.lastModifiedDate).fromNow();
|
|
fileSelectContainer.querySelector('#image-resolution-w').textContent = img.naturalWidth.toString();
|
|
fileSelectContainer.querySelector('#image-resolution-h').textContent = img.naturalHeight.toString();
|
|
|
|
fileSelectContainer.querySelector('#file-select').setAttribute('hidden', true);
|
|
fileSelectContainer.querySelector('#file-info').removeAttribute('hidden');
|
|
|
|
fileSelectContainer.querySelector('#file-save-btn').removeAttribute('hidden');
|
|
};
|
|
|
|
// set the image as the "src" of the <img> in the DOM.
|
|
img.src = e.target.result;
|
|
|
|
//z create cropper and set options here
|
|
this.createImageCropper(img);
|
|
};
|
|
|
|
// read in the file, which will trigger everything else in the event handler above.
|
|
reader.readAsDataURL(selectedFile);
|
|
}
|
|
|
|
async createImageCropper (img) {
|
|
this.log.info("createImageCropper", "Creating image cropper", { img });
|
|
this.cropper = new Cropper(img, {
|
|
aspectRatio: 1,
|
|
dragMode: 'move',
|
|
autoCropArea: 0.85,
|
|
restore: false,
|
|
guides: false,
|
|
center: false,
|
|
highlight: false,
|
|
cropBoxMovable: false,
|
|
cropBoxResizable: false,
|
|
toggleDragModeOnDblclick: false,
|
|
modal: true,
|
|
});
|
|
}
|
|
|
|
async attachTinyMCE (editor) {
|
|
editor.on('KeyDown', async (e) => {
|
|
if (dtp.autosaveTimeout) {
|
|
window.clearTimeout(dtp.autosaveTimeout);
|
|
delete dtp.autosaveTimeout;
|
|
}
|
|
dtp.autosaveTimeout = window.setTimeout(async ( ) => {
|
|
console.log('document autosave');
|
|
}, 1000);
|
|
if ((e.keyCode === 8 || e.keyCode === 46) && editor.selection) {
|
|
var selectedNode = editor.selection.getNode();
|
|
if (selectedNode && selectedNode.nodeName === 'IMG') {
|
|
console.log('removing image', selectedNode);
|
|
await dtp.app.deleteImage(selectedNode.src.slice(-24));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async deleteImage (imageId) {
|
|
try {
|
|
throw new Error(`would want to delete /image/${imageId}`);
|
|
} catch (error) {
|
|
UIkit.modal.alert(error.message);
|
|
}
|
|
}
|
|
|
|
async openPaymentModal ( ) {
|
|
await UIkit.modal('#donate-modal').show();
|
|
await UIkit.slider('#payment-slider').show(0);
|
|
}
|
|
|
|
async generateQRCode (event) {
|
|
const selectorQR = event.target.getAttribute('data-selector-qr');
|
|
const selectorPrompt = event.target.getAttribute('data-selector-prompt');
|
|
|
|
const targetAmount = event.target.getAttribute('data-amount');
|
|
const amountLabel = numeral(targetAmount).format('$0,0.00');
|
|
const currencyAmountLabel = numeral(targetAmount * this.exchangeRates[this.paymentCurrency].conversionRateUSD).format('0,0.0000000000000000');
|
|
const prompt = `Donate ${amountLabel} using ${this.paymentCurrencyLabel}.`;
|
|
|
|
event.preventDefault();
|
|
|
|
let targetUrl;
|
|
switch (this.paymentCurrency) {
|
|
case 'BTC':
|
|
targetUrl = `bitcoin:${dtp.channel.wallet.btc}?amount=${targetAmount}&message=Donation to ${dtp.channel.name}`;
|
|
break;
|
|
case 'ETH':
|
|
targetUrl = `ethereum:${dtp.channel.wallet.eth}?amount=${targetAmount}`;
|
|
break;
|
|
case 'LTC':
|
|
targetUrl = `litecoin:${dtp.channel.wallet.ltc}?amount=${targetAmount}&message=Donation to ${dtp.channel.name}`;
|
|
break;
|
|
}
|
|
|
|
try {
|
|
let elements;
|
|
|
|
const imageUrl = await QRCode.toDataURL(targetUrl);
|
|
|
|
elements = document.querySelectorAll(selectorQR);
|
|
elements.forEach((element) => element.setAttribute('src', imageUrl));
|
|
|
|
elements = document.querySelectorAll(selectorPrompt);
|
|
elements.forEach((e) => e.textContent = prompt);
|
|
|
|
elements = document.querySelectorAll('span.prompt-donate-amount');
|
|
elements.forEach((element) => {
|
|
element.textContent = amountLabel;
|
|
});
|
|
|
|
elements = document.querySelectorAll('span.prompt-donate-amount-crypto');
|
|
elements.forEach((element) => {
|
|
element.textContent = currencyAmountLabel;
|
|
});
|
|
|
|
elements = document.querySelectorAll('a.payment-target-link');
|
|
elements.forEach((element) => {
|
|
element.setAttribute('href', targetUrl);
|
|
});
|
|
|
|
const e = document.getElementById('payment-link');
|
|
e.setAttribute('href', targetUrl);
|
|
|
|
UIkit.slider('#payment-slider').show(2);
|
|
} catch (error) {
|
|
this.log.error('failed to generate QR code to image', { error });
|
|
UIkit.modal.alert(`Failed to generate QR code: ${error.message}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async setPaymentCurrency (currency) {
|
|
this.paymentCurrency = currency;
|
|
this.log.info('setPaymentCurrency', 'payment currency', { currency: this.paymentCurrency });
|
|
|
|
await this.updateExchangeRates();
|
|
switch (this.paymentCurrency) {
|
|
case 'BTC':
|
|
this.paymentCurrencyLabel = 'Bitcoin (BTC)';
|
|
break;
|
|
case 'ETH':
|
|
this.paymentCurrencyLabel = 'Ethereum (ETH)';
|
|
break;
|
|
case 'LTC':
|
|
this.paymentCurrencyLabel = 'Litecoin (LTC)';
|
|
break;
|
|
}
|
|
|
|
let elements = document.querySelectorAll('span.prompt-donate-currency');
|
|
elements.forEach((element) => {
|
|
element.textContent = this.paymentCurrencyLabel;
|
|
});
|
|
|
|
UIkit.slider('#payment-slider').show(1);
|
|
}
|
|
|
|
async updateExchangeRates ( ) {
|
|
const NOW = Date.now();
|
|
try {
|
|
let exchangeRates;
|
|
if (!window.localStorage.exchangeRates) {
|
|
exchangeRates = await this.loadExchangeRates();
|
|
this.log.info('updateExchangeRates', 'current exchange rates received and cached', { exchangeRates });
|
|
window.localStorage.exchangesRates = JSON.stringify(exchangeRates);
|
|
} else {
|
|
exchangeRates = JSON.parse(window.localStorage.exchangeRates);
|
|
if (exchangeRates.timestamp < (NOW - 60000)) {
|
|
exchangeRates = await this.loadExchangeRates();
|
|
this.log.info('updateExchangeRates', 'current exchange rates received and cached', { exchangeRates });
|
|
window.localStorage.exchangesRates = JSON.stringify(exchangeRates);
|
|
}
|
|
}
|
|
this.exchangeRates = exchangeRates.symbols;
|
|
} catch (error) {
|
|
this.log.error('updateExchangeRates', 'failed to fetch currency exchange rates', { error });
|
|
UIkit.modal.alert(`Failed to fetch current exchange rates: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async loadExchangeRates ( ) {
|
|
this.log.info('loadExchangeRates', 'fetching current exchange rates');
|
|
const response = await fetch('/crypto-exchange/current-rates');
|
|
if (!response.ok) {
|
|
throw new Error('Server error');
|
|
}
|
|
let exchangeRates = await response.json();
|
|
if (!exchangeRates.success) {
|
|
throw new Error(exchangeRates.message);
|
|
}
|
|
exchangeRates.timestamp = Date.now();
|
|
return exchangeRates;
|
|
}
|
|
|
|
async generateOtpQR (canvas, keyURI) {
|
|
QRCode.toCanvas(canvas, keyURI);
|
|
}
|
|
|
|
async removeImageFile (event) {
|
|
const imageType = (event.target || event.currentTarget).getAttribute('data-image-type');
|
|
try {
|
|
this.log.info('removeImageFile', 'request to remove image', event);
|
|
|
|
let response;
|
|
switch (imageType) {
|
|
case 'channel-app-icon':
|
|
const channelId = (event.target || event.currentTarget).getAttribute('data-channel-id');
|
|
response = await fetch(`/channel/${channelId}/app-icon`, {
|
|
method: 'DELETE',
|
|
});
|
|
break;
|
|
|
|
default:
|
|
throw new Error('Invalid image type');
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Server error');
|
|
}
|
|
|
|
await this.processResponse(response);
|
|
} catch (error) {
|
|
UIkit.modal.alert(`Failed to remove image: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async onCommentInput (event) {
|
|
const label = document.getElementById('comment-character-count');
|
|
label.textContent = numeral(event.target.value.length).format('0,0');
|
|
}
|
|
|
|
async showEmojiPicker (event) {
|
|
const targetElementName = (event.currentTarget || event.target).getAttribute('data-target-element');
|
|
this.emojiTargetElement = document.getElementById(targetElementName);
|
|
|
|
this.emojiPicker.togglePicker(this.emojiTargetElement);
|
|
}
|
|
|
|
async onEmojiSelected (selection) {
|
|
this.emojiTargetElement.value += selection.emoji;
|
|
}
|
|
|
|
async attachLinksListManager ( ) {
|
|
const ELEM_ID = '#links-list';
|
|
this.linksList = UIkit.sortable(ELEM_ID);
|
|
UIkit.util.on(ELEM_ID, 'stop', this.updateLinkOrders.bind(this));
|
|
}
|
|
|
|
async submitLinkForm (event, eventName) {
|
|
await this.submitForm(event, eventName);
|
|
|
|
const editorClear = event.target.hasAttribute('data-editor-clear');
|
|
if (editorClear) {
|
|
event.target.querySelectorAll('input').forEach((input) => input.value = '');
|
|
}
|
|
|
|
const editorId = event.target.getAttribute('data-editor-id');
|
|
document.querySelector(editorId).setAttribute('hidden', '');
|
|
|
|
return true;
|
|
}
|
|
|
|
async updateLinkOrders ( ) {
|
|
const list = document.querySelectorAll('ul#links-list li[data-link-id]');
|
|
let order = 0;
|
|
const updateOps = [ ];
|
|
list.forEach((item) => {
|
|
const _id = item.getAttribute('data-link-id');
|
|
updateOps.push({ _id, order: order++ });
|
|
});
|
|
this.log.info('view', 'links list update', { updateOps });
|
|
try {
|
|
const response = await fetch(`/link/sort`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ updateOps }),
|
|
});
|
|
dtp.app.processResponse(response);
|
|
} catch (error) {
|
|
UIkit.modal.alert(`Failed to update list order: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async deleteLink (event) {
|
|
const target = event.currentTarget || event.target;
|
|
const link = {
|
|
_id: target.getAttribute('data-link-id'),
|
|
label: target.getAttribute('data-link-label'),
|
|
};
|
|
|
|
/*
|
|
* Prompt for delete confirmation
|
|
*/
|
|
try {
|
|
await UIkit.modal.confirm(`You are deleting: ${link.label}. This will remove "${link.label}" and it's stats from your profile and dashboard.`);
|
|
} catch (error) {
|
|
return; // canceled
|
|
}
|
|
try {
|
|
this.log.debug('deleteLink', 'deleting link', { link });
|
|
const response = await fetch(`/link/${link._id}`, { method: 'DELETE' });
|
|
if (!response.ok) {
|
|
throw new Error('Server error');
|
|
}
|
|
await this.processResponse(response);
|
|
} catch (error) {
|
|
UIkit.modal.alert(`Failed to delete link: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async renderProfileStats (selector, data) {
|
|
try {
|
|
const canvas = document.querySelector(selector);
|
|
const ctx = canvas.getContext('2d');
|
|
this.charts.profileStats = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.map((item) => new Date(item.date)),
|
|
datasets: [
|
|
{
|
|
label: 'Link Visits',
|
|
data: data.map((item) => item.count),
|
|
borderColor: CHART_LINE_USER,
|
|
tension: 0.5,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
scales: {
|
|
yAxis: {
|
|
display: true,
|
|
ticks: {
|
|
color: AXIS_TICK_COLOR,
|
|
callback: (value) => {
|
|
return numeral(value).format(value > 1000 ? '0,0.0a' : '0,0');
|
|
},
|
|
},
|
|
grid: {
|
|
color: GRID_COLOR,
|
|
tickColor: GRID_TICK_COLOR,
|
|
},
|
|
},
|
|
|
|
x: {
|
|
type: 'time',
|
|
},
|
|
xAxis: {
|
|
display: false,
|
|
grid: {
|
|
color: GRID_COLOR,
|
|
tickColor: GRID_TICK_COLOR,
|
|
},
|
|
},
|
|
},
|
|
plugins: {
|
|
title: { display: false },
|
|
subtitle: { display: false },
|
|
legend: {
|
|
display: true,
|
|
position: 'bottom',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.log.error('renderProfileStats', 'failed to render profile stats', { error });
|
|
UIkit.modal.alert(`Failed to render chart: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
dtp.DtpSiteApp = DtpSiteApp;
|