/*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(); } } class canopyUXUtils{ constructor(){ } //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(`

Server Error:


${err.msg}

`); }); }catch(err){ console.error("Display Error Error:"); console.error(body); console.error("Display Error Error:"); console.error(err); } } //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){ //Define non-tooltip node values this.content = content; this.ajaxPopup = ajaxTooltip; this.cb = cb; this.id = Math.random(); //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{ this.tooltip.innerHTML = 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(){ document.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((window.innerWidth - (window.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 = `${window.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((window.innerHeight - (window.innerHeight - y)) > this.tooltip.getBoundingClientRect().height){ //Push it above the mouse this.tooltip.style.bottom = `${window.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 popup = class{ constructor(content, ajaxPopup = false, cb){ //Define non-popup node values this.content = content; this.ajaxPopup = ajaxPopup; this.cb = cb; //define popup nodes this.createPopup(); //fill popup nodes this.fillPopupContent(); } createPopup(){ //Check if another popup has already thrown a backer up if(document.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 document.removeEventListener('keydown', this.keyClose); } }).bind(this); //Add event listener to close popup when enter is hit document.addEventListener('keydown', this.keyClose); } async fillPopupContent(){ if(this.ajaxPopup){ this.contentDiv.innerHTML = await utils.ajax.getPopup(this.content); }else{ this.contentDiv.innerHTML = this.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 document.activeElement.blur(); //display the popup document.body.prepend(this.popupDiv); //if we created a popup backer if(this.popupBacker != null){ //display the popup backer document.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 = document.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){ //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; //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 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 = document.body.scrollWidth - document.body.getBoundingClientRect().width){ //Fix the page width 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(){ } async register(user, pass, passConfirm, email, verification){ var response = await fetch(`/api/account/register`,{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(email ? {user, pass, passConfirm, email, verification} : {user, pass, passConfirm, verification}) }); if(response.status == 200){ 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" }, body: JSON.stringify(verification ? {user, pass, verification} : {user, pass}) }); if(response.status == 200){ 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: "GET", }); if(response.status == 200){ 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" }, body: JSON.stringify(update) }); if(response.status == 200){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } async getRankEnum(){ var response = await fetch(`/api/account/rankEnum`,{ method: "GET" }); if(response.status == 200){ 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" }, body: JSON.stringify({pass}) }); if(response.status == 200){ 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" }, body: JSON.stringify({user, verification}) }); //If we received a successful response if(response.status == 200){ //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" }, body: JSON.stringify({token, pass, confirmPass, verification}) }); //If we received a successful response if(response.status == 200){ //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 newChannel(name, description, thumbnail, verification){ var response = await fetch(`/api/channel/register`,{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(thumbnail ? {name, description, thumbnail, verification} : {name, description, verification}) }); if(response.status == 200){ 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" }, //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.status == 200){ 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" }, //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.status == 200){ 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.status == 200){ 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" }, body: JSON.stringify({chanName, user, rank}) }); if(response.status == 200){ 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" }, body: JSON.stringify({chanName, confirm}) }); if(response.status == 200){ location = "/"; }else{ utils.ux.displayResponseError(await response.json()); } } async getPopup(popup){ var response = await fetch(`/popup/${popup}`,{ method: "GET" }); if(response.status == 200){ return (await response.text()) }else{ utils.ux.displayResponseError(await response.json()); } } async getTooltip(tooltip){ var response = await fetch(`/tooltip/${tooltip}`,{ method: "GET" }); if(response.status == 200){ return (await response.text()) }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.status == 200){ 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" }, body: JSON.stringify({chanName, user, expirationDays, banAlts}) }); if(response.status == 200){ 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" }, body: JSON.stringify({chanName, user}) }); if(response.status == 200){ 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.status == 200){ 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" }, body: JSON.stringify({chanName, command}) }); if(response.status == 200){ 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" }, body: JSON.stringify({chanName, command}) }); if(response.status == 200){ 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.status == 200){ 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" }, body: JSON.stringify({chanName, emoteName, link}) }); if(response.status == 200){ 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" }, body: JSON.stringify({chanName, emoteName}) }); if(response.status == 200){ return await response.json(); }else{ utils.ux.displayResponseError(await response.json()); } } } const utils = new canopyUtils()