// site-app.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; const DTP_COMPONENT = { name: 'Site App', slug: 'site-app' }; 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 SiteChat from './site-chat'; import SiteComments from './site-comments'; const GRID_COLOR = 'rgb(64, 64, 64)'; const GRID_TICK_COLOR = 'rgb(192,192,192)'; const AXIS_TICK_COLOR = 'rgb(192, 192, 192)'; const CHART_LINE_USER = 'rgb(0, 192, 0)'; const CHART_FILL_USER = 'rgb(0, 128, 0)'; export default class DtpSiteApp extends DtpApp { constructor (user) { super(DTP_COMPONENT, user); if (dtp.env === 'production') { const isSafari = !!navigator.userAgent.match(/Version\/[\d\.]+.*Safari/); const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; this.isIOS = isSafari || isIOS; } else { this.isIOS = false; } this.log.debug('constructor', 'app instance created', { env: dtp.env, isIOS: this.isIOS, }); this.chat = new SiteChat(this); this.comments = new SiteComments(this); this.charts = { /* will hold rendered charts */ }; this.scrollToHash(); } async scrollToHash ( ) { const { hash } = window.location; if (hash === '') { return; } const target = document.getElementById(hash.slice(1)); if (target && target.scrollIntoView) { target.scrollIntoView({ behavior: 'smooth' }); } } 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('system-message', this.chat.appendSystemMessage.bind(this.chat)); socket.on('user-chat', this.chat.appendUserChat.bind(this.chat)); socket.on('user-react', this.chat.createEmojiReact.bind(this.chat)); } } async goBack ( ) { if (document.referrer && (document.referrer.indexOf(`://${window.dtp.domain}`) >= 0)) { window.history.back(); } else { window.location.href= '/'; } return false; } async submitImageForm (event) { event.preventDefault(); event.stopPropagation(); const formElement = event.currentTarget || event.target; const form = new FormData(formElement); this.cropper.getCroppedCanvas().toBlob(async (imageData) => { try { form.append('imageFile', imageData, 'profile.png'); this.log.info('submitImageForm', 'updating user settings', { 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); window.location.reload(); } catch (error) { UIkit.modal.alert(`Failed to update profile photo: ${error.message}`); } }); return; } async closeCurrentDialog ( ) { if (!this.currentDialog) { return; } this.currentDialog.hide(); delete this.currentDialog; } 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 let cropperOptions = event.target.getAttribute('data-cropper-options'); if (cropperOptions) { cropperOptions = JSON.parse(cropperOptions); } 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 reader = new FileReader(); reader.onload = (e) => { const img = document.getElementById(imageId); img.onload = (e) => { console.log('image loaded', e, img.naturalWidth, img.naturalHeight); 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 in the DOM. img.src = e.target.result; //z create cropper and set options here this.createImageCropper(img, cropperOptions); }; // read in the file, which will trigger everything else in the event handler above. reader.readAsDataURL(selectedFile); } async createImageCropper (img, options) { options = Object.assign({ aspectRatio: 1, dragMode: 'move', autoCropArea: 0.85, restore: false, guides: false, center: false, highlight: false, cropBoxMovable: true, cropBoxResizable: true, toggleDragModeOnDblclick: false, modal: true, }, options); this.log.info("createImageCropper", "Creating image cropper", { img }); this.cropper = new Cropper(img, options); } 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 'profile-picture-file': response = await fetch(`/user/${this.user._id}/profile-photo`, { method: 'DELETE' }); break; case 'post-image-file'://this is UNFINISHED, figure out the route in controller, and implement it response = await fetch(`/post/${this.user._id}/feature-image`, { method: 'DELETE' }); break; default: throw new Error('Invalid image type'); } if (!response.ok) { throw new Error('Server error'); } await this.processResponse(response); window.location.reload(); } catch (error) { UIkit.modal.alert(`Failed to remove image: ${error.message}`); } } async submitDialogForm (event, userAction) { await this.submitForm(event, userAction); await this.closeCurrentDialog(); } async renderStatsGraph (selector, title, data) { try { const canvas = document.querySelector(selector); const ctx = canvas.getContext('2d'); this.charts.profileStats = new Chart(ctx, { type: 'bar', data: { labels: data.map((item) => new Date(item.date)), datasets: [ { label: title, data: data.map((item) => item.count), borderColor: CHART_LINE_USER, borderWidth: 1, backgroundColor: CHART_FILL_USER, tension: 0, }, ], }, 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: false }, }, maintainAspectRatio: true, aspectRatio: 16.0 / 9.0, onResize: (chart, event) => { if (event.width >= 960) { chart.config.options.aspectRatio = 16.0 / 5.0; } else if (event.width >= 640) { chart.config.options.aspectRatio = 16.0 / 9.0; } else if (event.width >= 480) { chart.config.options.aspectRatio = 16.0 / 12.0; } else { chart.config.options.aspectRatio = 16.0 / 16.0; } }, }, }); } catch (error) { this.log.error('renderStatsGraph', 'failed to render stats graph', { title, error }); UIkit.modal.alert(`Failed to render chart: ${error.message}`); } } async deletePost (event) { event.preventDefault(); event.stopPropagation(); const target = event.currentTarget || event.target; const postId = target.getAttribute('data-post-id'); const postTitle = target.getAttribute('data-post-title'); try { await UIkit.modal.confirm(`Are you sure you want to remove post "${postTitle}"?`); } catch (error) { // canceled return false; } try { const response = await fetch(`/post/${postId}`, { method: 'DELETE' }); await this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to remove post: ${error.message}`); } return false; } async deletePage (event) { event.preventDefault(); event.stopPropagation(); const target = event.currentTarget || event.target; const pageId = target.getAttribute('data-page-id'); const pageTitle = target.getAttribute('data-page-title'); try { await UIkit.modal.confirm(`Are you sure you want to remove page "${pageTitle}"?`); } catch (error) { // canceled return false; } try { const response = await fetch(`/page/${pageId}`, { method: 'DELETE' }); await this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to remove page: ${error.message}`); } return false; } updatePostTimestamps () { const timestamps = document.querySelectorAll('[data-dtp-timestamp]'); // console.log(timestamps); timestamps.forEach((timestamp) => { const postTime = timestamp.getAttribute('data-dtp-timestamp'); const format = timestamp.getAttribute('data-dtp-time-format'); timestamp.textContent = moment(postTime).format(format || 'MMM DD, YYYY, hh:mm a'); }); } } dtp.DtpSiteApp = DtpSiteApp;