/*Canopy - The next generation of stoner streaming software Copyright (C) 2024-2025 Rainbownapkin and the TTN Community This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see .*/ class canopyUtils{ constructor(){ this.ajax = new canopyAjaxUtils(); this.ux = new canopyUXUtils(); } //somehow this isn't built in to JS's unescape functions... unescapeEntities(string){ //Create a new DOMParser and tell it to parse string as HTML const outNode = new DOMParser().parseFromString(string, "text/html"); //Grab text content and send that shit out return outNode.documentElement.textContent; } escapeRegex(string){ /* I won't lie this line was whole-sale ganked from stack overflow like a fucking skid In my defense I only did it because browser-devs are taking fucking eons to implement RegExp.escape() This should be replaced once that function becomes available in mainline versions of firefox/chromium: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape */ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } isSameDate(d0, d1){ return d0.getYear() == d1.getYear() && d0.getMonth() == d1.getMonth() && d0.getDate() == d1.getDate(); } dateWithinRange(min, max, input){ return min.getTime() < input.getTime() && max.getTime() > input.getTime(); } } class canopyUXUtils{ constructor(){ } async awaitNextFrame(){ //return a new promise return new Promise((resolve)=>{ //Before the next frame requestAnimationFrame(()=>{ //fires before next-next frame (after next frame) requestAnimationFrame(()=>{ //resolve the promise resolve(); }); }); }) } //Useful for pesky date/time input conversions localizeDate(date){ //Apply timezone offset to base (utc) time return new Date(date.getTime() - (date.getTimezoneOffset() * 60000)); } //Useful for pesky date/time input conversions normalizeDate(date){ //Convert dates which store local time to utc to dates which store utc as utc return new Date(date.getTime() + (date.getTimezoneOffset() * 60000)); } timeStringFromDate(date, displaySeconds = true){ let outString = '' //If scale is over a minute then we don't need to display seconds const seconds = displaySeconds ? `:${('0' + date.getSeconds()).slice(-2)}` : '' //If we're counting AM if(date.getHours() < 12){ //Display as AM outString = `${('0'+date.getHours()).slice(-2)}:${('0' + date.getMinutes()).slice(-2)}${seconds}AM` //If we're cointing noon }else if(date.getHours() == 12){ //display as noon outString = `${('0'+date.getHours()).slice(-2)}:${('0' + date.getMinutes()).slice(-2)}${seconds}PM` //if we're counting pm }else{ //display as pm outString = `${('0'+(date.getHours() - 12)).slice(-2)}:${('0' + date.getMinutes()).slice(-2)}${seconds}PM` } return outString; } //Update this and popup class to use nodes //and display multiple errors in one popup displayResponseError(body){ try{ const errors = body.errors; errors.forEach((err)=>{ new canopyUXUtils.popup(`

${err.type} Error:


${err.msg}

`); }); }catch(err){ console.error("Display Error Body:"); console.error(body); console.error("Display Error Error:"); console.error(err); } } displayTooltip(event, content, ajaxTooltip, cb, soft = false, doc = document){ //Create the tooltip const tooltip = new canopyUXUtils.tooltip(content, ajaxTooltip, ()=>{ //If this is an ajax tooltip if(ajaxTooltip){ //Call mouse move again after ajax load to re-calculate position within context of the new content tooltip.moveToMouse(event); } //If we have a callback function if(typeof cb == "function"){ //Call async callback cb(); } }, doc); //Move the tooltip with the mouse event.target.addEventListener('mousemove', tooltip.moveToMouse.bind(tooltip)); //Do intial mouse move tooltip.moveToMouse(event); //remove the tooltip on mouseleave event.target.addEventListener('mouseleave', tooltip.remove.bind(tooltip)); //Kill tooltip with parent doc.body.addEventListener('mousemove', killWithParent); if(soft){ //remove the tooltip on context menu open event.target.addEventListener('mousedown', tooltip.remove.bind(tooltip)); event.target.addEventListener('contextmenu', tooltip.remove.bind(tooltip)); } function killWithParent(){ //If the tooltip parent no longer exists if(!event.target.isConnected){ //Kill the whole family :D tooltip.remove(); } } } displayContextMenu(event, title, menuMap, doc){ event.preventDefault(); //Create context menu const contextMenu = new canopyUXUtils.contextMenu(title, menuMap, doc); //Move context menu to mouse contextMenu.moveToMouse(event); } //We should really move this over to uxutils along with newrow & newtable functions newTableCell(content, firstCol = false){ //Create a new 'td' element const cell = document.createElement('td'); //If it's not the first column, mention it! if(!firstCol){ cell.classList.add("not-first-col"); } //check for arrays if(content.forEach == null){ //add single items addContent(content); }else{ //Crawl through content array content.forEach((item)=>{ //add each item addContent(item); }); } //return the resulting cell return cell; function addContent(ct){ //If we're adding as node if(ct.cloneNode != null){ //append it like it's a node cell.appendChild(ct); }else{ //otherwise use it as innerHTML cell.innerHTML = ct; } } } newTableRow(cellContent){ //Create an empty table row to hold the cells const entryRow = document.createElement('tr'); entryRow.classList.add("gen-row"); entryRow.appendChild(this.newTableCell(cellContent[0], true)); cellContent.forEach((content, i)=>{ if(i > 0){ entryRow.append(this.newTableCell(content)); } }); return entryRow; } static tooltip = class{ constructor(content, ajaxTooltip = false, cb, doc = document){ //Define non-tooltip node values this.content = content; this.ajaxPopup = ajaxTooltip; this.cb = cb; this.id = Math.random(); this.doc = doc; //create and append tooltip this.tooltip = document.createElement('div'); this.tooltip.classList.add('tooltip'); //Display tooltip even if it's not loaded to prevent removal of unloaded tooltips this.displayTooltip(); //Fill the tooltip this.fillTooltipContent(); } async fillTooltipContent(){ if(this.ajaxPopup){ this.tooltip.textContent = "Loading tooltip..." this.tooltip.innerHTML = await utils.ajax.getTooltip(this.content); }else{ //If the content we received is a string if(typeof this.content == "string"){ //Use it this.tooltip.innerHTML = this.content; //Otherwise }else{ //Append it as a node this.tooltip.appendChild(this.content); } } if(this.cb){ //Callbacks are kinda out of vogue, but there really isn't a good way to handle asynchronously constructed objects/classes this.cb(); } } displayTooltip(){ this.doc.body.appendChild(this.tooltip); } moveToPos(x,y){ //If the distance between the left edge of the window - the window width is more than the width of our tooltip if((this.doc.defaultView.innerWidth - (this.doc.defaultView.innerWidth - x)) > this.tooltip.getBoundingClientRect().width){ //Push it to the right edge of the cursor, where the hard edge typically is this.tooltip.style.right = `${this.doc.defaultView.innerWidth - x}px`; this.tooltip.style.left = ''; //otherwise, if we're close to the edge }else{ //push it away from the edge of the screen this.tooltip.style.right = '' this.tooltip.style.left = `${x}px` } //If the distance between the top edge of the window - the window height is more than the heigt of our tooltip if((this.doc.defaultView.innerHeight - (this.doc.defaultView.innerHeight - y)) > this.tooltip.getBoundingClientRect().height){ //Push it above the mouse this.tooltip.style.bottom = `${this.doc.defaultView.innerHeight - y}px`; this.tooltip.style.top = ''; //otherwise if we're close to the edge }else{ //Push it below the mouse to avoid the top edge of the screen //-50px to account for a normal sized cursor this.tooltip.style.bottom = ''; this.tooltip.style.top = `${y+15}px`; } } moveToMouse(event){ this.moveToPos(event.clientX, event.clientY) } remove(){ this.tooltip.dispatchEvent(new Event("close")); this.tooltip.remove(); } } static contextMenu = class extends this.tooltip{ constructor(title, menuMap, doc = document){ //Call inherited tooltip constructor super('Loading Menu...', false, null, doc); //Set tooltip class this.tooltip.classList.add('context-menu'); //Set title and menu map this.title = title; this.menuMap = menuMap; this.constructMenu(); } constructMenu(){ //Clear out tooltip this.tooltip.innerHTML = ''; //Create menu title const menuTitle = document.createElement('h2'); menuTitle.innerHTML = this.title; //Append the title to the tooltip this.tooltip.append(menuTitle); for(let choice of this.menuMap){ //Create button const button = document.createElement('button'); button.innerHTML = choice[0]; //Add event listeners button.addEventListener('click', choice[1]); //Append the button to the menu div this.tooltip.appendChild(button); } //Create event listener to remove tooltip whenever anything is clicked, inside or out of the menu //Little hacky but we have to do it a bit later to prevent the opening event from closing the menu setTimeout(()=>{this.doc.body.addEventListener('click', this.remove.bind(this));}, 1); setTimeout(()=>{this.doc.body.addEventListener('contextmenu', this.remove.bind(this));}, 1); } } static popup = class{ constructor(content, ajaxPopup = false, cb, doc = document){ //Define non-popup node values this.content = content; this.ajaxPopup = ajaxPopup; this.cb = cb; this.doc = doc; //define popup nodes this.createPopup(); //fill popup nodes this.fillPopupContent(); } createPopup(){ //Check if another popup has already thrown a backer up if(this.doc.querySelector('.popup-backer') == null){ //Create popup backer this.popupBacker = document.createElement('div'); this.popupBacker.classList.add('popup-backer'); } //Create popup this.popupDiv = document.createElement('div'); this.popupDiv.classList.add('popup-div'); //Create close icon this.closeIcon = document.createElement('i'); this.closeIcon.classList.add('bi-x','popup-close-icon'); this.closeIcon.addEventListener("click", this.closePopup.bind(this)); //Create content div this.contentDiv = document.createElement('div'); this.contentDiv.classList.add('popup-content-div'); //Append popup innards this.popupDiv.appendChild(this.closeIcon); this.popupDiv.appendChild(this.contentDiv); //Bit hacky but the only way to remove an event listener while keeping the function bound to this //Isn't javascript precious? this.keyClose = ((event)=>{ //If we hit enter or escape if(event.key == "Enter" || event.key == "Escape"){ //Close the pop-up this.closePopup(); //Remove this event listener this.doc.removeEventListener('keydown', this.keyClose); } }).bind(this); //Add event listener to close popup when enter is hit this.doc.addEventListener('keydown', this.keyClose); } async fillPopupContent(){ if(this.ajaxPopup){ this.contentDiv.innerHTML = await utils.ajax.getPopup(this.content); }else{ this.contentDiv.innerHTML = this.content; this.contentDiv.classList.add('popup-plaintext-content'); } //display popup nodes this.displayPopup(); if(this.cb){ //Callbacks are kinda out of vogue, but there really isn't a good way to handle asynchronously constructed objects/classes this.cb(); } } displayPopup(){ //Blur active element that probably caused the popup this.doc.activeElement.blur(); //display the popup this.doc.body.prepend(this.popupDiv); //if we created a popup backer if(this.popupBacker != null){ //display the popup backer this.doc.body.prepend(this.popupBacker); } } closePopup(){ this.popupDiv.dispatchEvent(new Event("close")); //Take out the popup this.popupDiv.remove(); //Look for the backer instead of using the object property since the bitch mighta been created by someone else const foundBacker = this.doc.querySelector('.popup-backer'); //if there aren't any more popups if(document.querySelector('.popup-div') == null && foundBacker != null){ //Take out the backer foundBacker.remove(); } } } static clickDragger = class{ constructor(handle, element, leftHandle = true, parent, flex = true){ //Pull needed nodes this.handle = document.querySelector(handle); this.element = document.querySelector(element); //True while dragging this.dragLock = false; //Come to the ~~dark~~ left side this.leftHandle = leftHandle //True when the user is actively breaking the screen by dragging too far off to the side this.breakingScreen = false; //we put a click dragger in yo click dragger so you could click and drag while you click and drag this.parent = parent; //Whether or not click dragger is in a flexbox this.flex = flex; //Setup our event listeners this.setupInput(); } setupInput(){ this.handle.addEventListener("mousedown", this.startDrag.bind(this)); this.element.parentElement.addEventListener("mouseup", this.endDrag.bind(this)); this.element.parentElement.addEventListener("mousemove", this.drag.bind(this)); } startDrag(event){ //we are now dragging this.dragLock = true; //Keep user from selecting text or changing cursor as we drag window.document.body.style.userSelect = "none"; window.document.body.style.cursor = "ew-resize"; } endDrag(event){ //we're no longer dragging this.dragLock = false; //This is only relevant when dragging, keeping this on can cause issues after content changes this.breakingScreen = false; //Return cursor to normal, and allow user to select text again window.document.body.style.userSelect = "auto"; window.document.body.style.cursor = "auto"; } drag(event){ if(this.dragLock){ if(this.leftHandle){ //get difference between mouse and right edge of element var difference = this.element.getBoundingClientRect().right - event.clientX; }else{ //get difference between mouse and left edge of element var difference = event.clientX - this.element.getBoundingClientRect().left; } //check if we have a scrollbar because we're breaking shit var pageBreak = document.body.scrollWidth - document.body.getBoundingClientRect().width; //if we're not breaking the page, or we're moving left if((!this.breakingScreen && pageBreak <= 0) || event.clientX < this.handle.getBoundingClientRect().left){ //Apply difference to width if(this.flex){ this.element.style.flexBasis = `${this.calcWidth(difference)}vw`; } //I know it's kludgy to apply this along-side flex basis but it fixes some nasty bugs with nested draggers //Don't @ me, it's not like i'm an actual web developer anyways :P this.element.style.width = `${this.calcWidth(difference)}vw`; //If we let go here, the width isn't breaking anything so there's nothing to fix. this.breakingScreen = false; }else{ //call fixCutoff with standalone mode off, and a pre-calculated pageBreak if(!this.breakingScreen){ this.fixCutoff(false, pageBreak); } } } } calcWidth(px){ //I cannot fucking believe they added a clamp function to CSS before JS... const width = (px / window.innerWidth) * 100; //Little hacky but this ensures that if our click dragger tries to do something that it should absolutely never be doing //it'll be told to sit down, shut up, and fuck off. return width < 100 ? width : 20; } fixCutoff(standalone = true, pageBreak){ //If we have no pagebreak if(pageBreak == null){ //If we have a document body if(document.body != null){ pageBreak = document.body.scrollWidth - document.body.getBoundingClientRect().width //Otherwise }else{ //Pretend nothing happened because we probably have bigger issues then a fucked up click-dragger cutoff return; } } //Fix the page width if(this.flex){ this.element.style.flexBasis = `${this.calcWidth(this.element.getBoundingClientRect().width + pageBreak)}vw`; } this.element.style.width = `${this.calcWidth(this.element.getBoundingClientRect().width + pageBreak)}vw`; //If we're calling this outside of drag() (regardless of draglock unless set otherwise) if(standalone){ //call end drag to finish the job this.endDrag(); }else{ //We should let the rest of the object know we're breaking the screen this.breakingScreen = true; } //If we have a parent dragger if(this.parent != null){ //Make sure to fix it's cutoff too this.parent.fixCutoff(standalone); } } } } class canopyAjaxUtils{ constructor(){ } //Account async register(user, pass, passConfirm, email, verification){ var response = await fetch(`/api/account/register`,{ method: "POST", headers: { "Content-Type": "application/json", //It's either this or find and bind all event listeners :P "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify(email ? {user, pass, passConfirm, email, verification} : {user, pass, passConfirm, verification}) }); if(response.ok){ location = "/"; }else{ utils.ux.displayResponseError(await response.json()); } } async login(user, pass, verification){ var response = await fetch(`/api/account/login`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify(verification ? {user, pass, verification} : {user, pass}) }); if(response.ok){ location.reload(); }else if(response.status == 429){ location = `/login?user=${user}`; }else{ utils.ux.displayResponseError(await response.json()); } } async logout(){ var response = await fetch(`/api/account/logout`,{ method: "POST", headers: { "x-csrf-token": utils.ajax.getCSRFToken() } }); if(response.ok){ location.reload(); }else{ utils.ux.displayResponseError(await response.json()); } } async updateProfile(update){ const response = await fetch(`/api/account/update`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify(update) }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async getRankEnum(){ var response = await fetch(`/api/account/rankEnum`,{ method: "GET" }); if(response.ok){ return (await response.json()) }else{ utils.ux.displayResponseError(await response.json()); } } async deleteAccount(pass){ const response = await fetch(`/api/account/delete`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({pass}) }); if(response.ok){ window.location.pathname = '/'; }else{ utils.ux.displayResponseError(await response.json()); } } async requestPasswordReset(user, verification){ const response = await fetch(`/api/account/passwordResetRequest`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({user, verification}) }); //If we received a successful response if(response.ok){ //Create pop-up const popup = new canopyUXUtils.popup("A password reset link has been sent to the email associated with the account requested assuming it has one!"); //Go to home-page on pop-up closure popup.popupDiv.addEventListener("close", ()=>{window.location = '/'}); //Otherwise }else{ utils.ux.displayResponseError(await response.json()); } } async resetPassword(token, pass, confirmPass, verification){ const response = await fetch(`/api/account/passwordReset`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({token, pass, confirmPass, verification}) }); //If we received a successful response if(response.ok){ //Create pop-up const popup = new canopyUXUtils.popup("Your password has been reset, and all devices have been logged out of your account!"); //Go to home-page on pop-up closure popup.popupDiv.addEventListener("close", ()=>{window.location = '/'}); //Otherwise }else{ utils.ux.displayResponseError(await response.json()); } } async requestEmailChange(email, pass){ const response = await fetch(`/api/account/emailChangeRequest`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({email, pass}) }); //If we received a successful response if(response.ok){ const popup = new canopyUXUtils.popup("A confirmation link has been sent to the new email address."); //Otherwise }else{ utils.ux.displayResponseError(await response.json()); } } //Channel async newChannel(name, description, thumbnail, verification){ var response = await fetch(`/api/channel/register`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify(thumbnail ? {name, description, thumbnail, verification} : {name, description, verification}) }); if(response.ok){ location = "/"; }else{ utils.ux.displayResponseError(await response.json()); } } async setChannelSetting(chanName, settingsMap){ var response = await fetch(`/api/channel/settings`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, //Unfortunately JSON doesn't natively handle ES6 maps, and god forbid someone update the standard in a way that's backwards compatible... body: JSON.stringify({chanName, settingsMap: Object.fromEntries(settingsMap)}) }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async setChannelPermissions(chanName, permissionsMap){ var response = await fetch(`/api/channel/permissions`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, //Unfortunately JSON doesn't natively handle ES6 maps, and god forbid someone update the standard in a way that's backwards compatible... body: JSON.stringify({chanName, channelPermissionsMap: Object.fromEntries(permissionsMap)}) }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async getChannelRank(chanName){ var response = await fetch(`/api/channel/rank?chanName=${chanName}`,{ method: "GET" }); if(response.ok){ return (await response.json()) }else{ utils.ux.displayResponseError(await response.json()); } } async setChannelRank(chanName, user, rank){ var response = await fetch(`/api/channel/rank`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({chanName, user, rank}) }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async getChanBans(chanName){ var response = await fetch(`/api/channel/ban?chanName=${chanName}`,{ method: "GET", headers: { "Content-Type": "application/json" } }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async chanBan(chanName, user, expirationDays, banAlts){ var response = await fetch(`/api/channel/ban`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({chanName, user, expirationDays, banAlts}) }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async chanUnban(chanName, user){ var response = await fetch(`/api/channel/ban`,{ method: "DELETE", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({chanName, user}) }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async getChanTokes(chanName){ var response = await fetch(`/api/channel/tokeCommand?chanName=${chanName}`,{ method: "GET", headers: { "Content-Type": "application/json" } }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async addChanToke(chanName, command){ var response = await fetch('/api/channel/tokeCommand',{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({chanName, command}) }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async deleteChanToke(chanName, command){ var response = await fetch('/api/channel/tokeCommand',{ method: "DELETE", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({chanName, command}) }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async getChanEmotes(chanName){ var response = await fetch(`/api/channel/emote?chanName=${chanName}`,{ method: "GET", headers: { "Content-Type": "application/json" } }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async addChanEmote(chanName, emoteName, link){ var response = await fetch('/api/channel/emote',{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({chanName, emoteName, link}) }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async deleteChanEmote(chanName, emoteName){ var response = await fetch('/api/channel/emote',{ method: "DELETE", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({chanName, emoteName}) }); if(response.ok){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async deleteChannel(chanName, confirm){ var response = await fetch(`/api/channel/delete`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, body: JSON.stringify({chanName, confirm}) }); if(response.ok){ location = "/"; }else{ utils.ux.displayResponseError(await response.json()); } } //Popup async getPopup(popup){ var response = await fetch(`/popup/${popup}`,{ method: "GET" }); if(response.ok){ return (await response.text()) }else{ utils.ux.displayResponseError(await response.json()); } } //Tooltip async getTooltip(tooltip){ var response = await fetch(`/tooltip/${tooltip}`,{ method: "GET" }); if(response.ok){ return (await response.text()) }else{ utils.ux.displayResponseError(await response.json()); } } //Syntatic sugar getCSRFToken(){ return document.querySelector("[name='csrf-token']").content; } } const utils = new canopyUtils()