/*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 channelSettingsPage{ constructor(){ //Get channel name off of the URL this.channel = window.location.pathname.slice(3).replace('/settings',''); //Instantiate UX handling objects, making sure to pass the channel name. this.chanInfo = new chanInfo(this.channel); this.rankList = new rankList(this.channel); this.banList = new banList(this.channel); this.permList = new permList(this.channel); this.prefrenceList = new prefrenceList(this.channel); this.tokeCommandList = new tokeCommandList(this.channel); this.emoteList = new emoteList(this.channel); this.deleteBtn = new deleteBtn(this.channel); } } class chanInfo{ constructor(channel){ //Define channel this.channel = channel; //Define UX elements this.thumbnail = document.querySelector("#channel-info-thumbnail"); this.thumbnailInput = document.querySelector("#channel-info-thumbnail-prompt"); this.description = document.querySelector("#channel-info-description"); //Create description prompt this.descriptionInput = document.createElement("textarea"); this.descriptionInput.id = "channel-info-description-prompt"; //Setup Input Event Handlers this.setupInput(); } setupInput(){ this.thumbnail.addEventListener('click', ()=>{this.toggleThumbnailPrompt(true)}); this.thumbnailInput.addEventListener('keydown', this.submitThumbnail.bind(this)); this.description.addEventListener('click', ()=>{this.toggleDescriptionPrompt(true)}) this.descriptionInput.addEventListener('keydown', this.submitDescription.bind(this)); } toggleThumbnailPrompt(enabled){ this.thumbnailInput.style.display = enabled ? "" : "none"; if(enabled){ this.thumbnail.classList.remove('interactive'); }else{ this.thumbnail.classList.add('interactive'); } } submitThumbnail(event){ //If we hit didnt hit enter or escape if(!(event.key == "Enter" || event.key == "Escape") && event.key != null){ //bail! return; } //Toggle prompt this.toggleThumbnailPrompt(false); //Only returns after this point if(event.key != "Enter" && event.key != null){ return; } } toggleDescriptionPrompt(enabled){ if(enabled){ this.description.replaceWith(this.descriptionInput); }else{ this.descriptionInput.replaceWith(this.description); } } submitDescription(event){ //If we hit didnt hit enter (without shift) or escape if(!((event.key == "Enter" && !event.shiftKey) || event.key == "Escape") && event.key != null){ //bail! return; } //Toggle prompt this.toggleDescriptionPrompt(false); //Only returns after this point if(event.key != "Enter" && !event.shiftKey && event.key != null){ return; } } } class rankList{ constructor(channel){ this.channel = channel this.table = document.querySelector(".admin-list-table"); this.userPrompt = document.querySelector("#new-rank-input"); this.rankSelect = document.querySelector("#new-rank-select"); //Load the userlist and setup input this.loadList(); this.setupInput(); } setupInput(){ this.userPrompt.addEventListener("keydown", this.submitNewRank.bind(this)); } async loadList(){ const list = await utils.ajax.getChannelRank(this.channel); this.updateList(list); } async submitNewRank(event){ if((event.key != "Enter" ) && event.key != null){ //Bail out if we didn't hit enter return; } //Send new rank this.submitUserRank(this.userPrompt.value, this.rankSelect.value); //Clear out prompt this.userPrompt.value = ""; } async submitUpdate(event){ const user = event.target.id.replace("channel-rank-select-",""); const rank = event.target.value; await this.submitUserRank(user, rank); } async submitUserRank(user, rank){ await this.updateList(await utils.ajax.setChannelRank(this.channel, user, rank)); } async updateList(data){ //If no data if(!data){ //Do not pass go, do not collect $200 return; } //Get name/rank of logged in user const curName = document.querySelector("#username").textContent const curUser = data[curName]; const rankEnum = await utils.ajax.getRankEnum(); //clear the table this.clearTable(); //For each user in the list Object.entries(data).forEach((userAr) => { //pull user object from entry array const user = userAr[1]; //Create IMG node inside of IMG cell const imgNode = document.createElement('img'); imgNode.classList.add("admin-list-entry","admin-list-entry-item"); imgNode.src = user.img; //If the listed user rank is equal or higher than the signed-in user if(rankEnum.indexOf(user.rank) >= rankEnum.indexOf(curUser.rank)){ var rankContent = user.rank; }else{ //Create rank select var rankContent = document.createElement('select'); rankContent.id = `channel-rank-select-${user.user}` rankContent.classList.add("channel-rank-select") rankContent.addEventListener("change", this.submitUpdate.bind(this)); //for each rank in the enum rankEnum.slice().reverse().forEach((rank) => { //Create an option for the given rank const rankOption = document.createElement('option'); rankOption.value = rank; rankOption.textContent = utils.unescapeEntities(rank); rankOption.selected = user.rank == rank; rankContent.appendChild(rankOption); }); } //Generate row and append to table this.table.appendChild(utils.ux.newTableRow([ imgNode, user.id, user.user, rankContent ])); }); } clearTable(){ //get all non-title table rows const rows = this.table.querySelectorAll("tr.gen-row"); //for each row rows.forEach((row) => { //The Lord Yeeteth, and The Lord Yoinketh away... row.remove(); }); } } class banList{ constructor(channel){ this.channel = channel; this.table = document.querySelector("#admin-ban-list-table"); this.banPrompt = document.querySelector("#new-ban-input"); this.banButton = document.querySelector("#new-ban-button"); this.loadList(); this.setupInput(); } setupInput(){ this.banButton.addEventListener('click', this.ban.bind(this)); } async loadList(){ const data = await utils.ajax.getChanBans(this.channel); this.updateList(data); } clearList(){ const oldRows = this.table.querySelectorAll('tr.gen-row'); oldRows.forEach((row) => { row.remove(); }); } async ban(event){ new chanBanUserPopup(this.channel, this.banPrompt.value, this.updateList.bind(this)); } async unban(event){ //Rip user outta the target id const user = event.target.id.replace("admin-user-list-unban-icon-",""); //Tell the server to unban them and get the list returned const list = await utils.ajax.chanUnban(this.channel, user); //Use the list to update the UI this.updateList(list); } updateList(bans){ //for each ban listed this.clearList(); bans.forEach((ban)=>{ //Create IMG node const imgNode = document.createElement('img'); imgNode.classList.add("admin-list-entry","admin-list-entry-item"); imgNode.src = ban.user.img; //Create banAlts check const banAlts = document.createElement('i'); banAlts.classList.add("admin-user-list-ban-alts",ban.banAlts ? "bi-check" : "bi-x"); //Create unban icon node const unbanIcon = document.createElement('i'); unbanIcon.classList.add("bi-emoji-smile-fill","admin-user-list-icon","admin-user-list-unban-icon"); unbanIcon.id = `admin-user-list-unban-icon-${ban.user.user}`; unbanIcon.title = `Unban ${ban.user.user}`; unbanIcon.addEventListener("click", this.unban.bind(this)); //THIS IS NOT YET PROPERLY IMPLEMENTED AS getDaysUntilExpiration has not been made for chan bans const expirationString = ban.expirationDays < 0 ? "Never" : `${new Date(ban.expirationDate).toDateString()}
(${ban.daysUntilExpiration} day(s) left)`; //Generate row and append to table this.table.appendChild(utils.ux.newTableRow([ imgNode, ban.user.id, ban.user.user, banAlts, new Date(ban.banDate).toDateString(), expirationString, unbanIcon ])); }); } } class prefrenceList{ constructor(channel){ this.channel = channel; this.inputs = document.querySelectorAll(".channel-preference-list-item"); this.setupInput(); } setupInput(){ this.inputs.forEach((input) => { input.addEventListener("change", this.submitUpdate.bind(this)); }); } async submitUpdate(event){ //Get key from event target const key = event.target.id.replace("channel-preference-",""); //Pull value from event target let value = event.target.value; //If this is a checkmark if(event.target.type == "checkbox"){ //Use the .checked property instead of .value value = event.target.checked; } //Create settings map const settingsMap = new Map([ [key, value] ]); //Send update and collect results const update = await utils.ajax.setChannelSetting(this.channel, settingsMap); //Handle update from server this.handleUpdate(update, event.target, key); } handleUpdate(data, target, key){ if(data){ target = data[key]; } } } class permList{ constructor(channel){ this.channel = channel this.inputs = document.querySelectorAll(".channel-perm-select"); this.setupInput(); } setupInput(){ this.inputs.forEach((input) => { input.addEventListener("change", this.submitUpdate.bind(this)); }); } async submitUpdate(event){ const key = event.target.id.replace("admin-perm-list-rank-select-",""); const value = event.target.value; const permMap = new Map([ [key, value] ]); this.handleUpdate(await utils.ajax.setChannelPermissions(this.channel, permMap), event.target, key); } handleUpdate(data, target, key){ target.value = data[key]; } } class tokeCommandList{ constructor(channel){ this.channel = channel; this.tokeCommandList = document.querySelector('div.toke-command-list'); this.newTokeCommandPrompt = document.querySelector('#new-toke-command-input'); this.newTokeCommandButton = document.querySelector('#new-toke-command-button'); //Setup input this.setupInput(); //Pull the toke list on load this.getTokeList(); } setupInput(){ this.newTokeCommandButton.addEventListener('click', this.addToke.bind(this)); } async addToke(event){ //Send out the new toke command and get the new list const tokeList = await utils.ajax.addChanToke(this.channel, this.newTokeCommandPrompt.value); //clear the prompt this.newTokeCommandPrompt.value = ""; //render the returned list this.renderTokeList(tokeList); } async getTokeList(){ const tokeList = await utils.ajax.getChanTokes(this.channel); this.renderTokeList(tokeList); } clearTokeList(){ this.tokeCommandList.innerHTML = ""; } async deleteToke(event){ const name = event.target.id.replace("toke-command-delete-",""); const tokeList = await utils.ajax.deleteChanToke(this.channel, name); this.renderTokeList(tokeList); } renderTokeList(tokeList){ if(tokeList != null){ //Clear our the toke list this.clearTokeList(); //For each toke in the received list tokeList.forEach((toke)=>{ //generate a toke command span, and append it to the toke list div this.tokeCommandList.appendChild(this.generateTokeSpan(toke)); }); } } generateTokeSpan(toke){ //Create toke command span const tokeSpan = document.createElement('span'); tokeSpan.classList.add('toke-command-list'); //Create toke command label const tokeLabel = document.createElement('p'); tokeLabel.textContent = utils.unescapeEntities(`!${toke}`); tokeLabel.classList.add('toke-command-list'); //Create toke command delete icon const tokeDelete = document.createElement('i'); tokeDelete.classList.add('toke-command-list', 'bi-trash-fill', 'toke-command-delete'); tokeDelete.id = `toke-command-delete-${toke}`; tokeDelete.addEventListener('click', this.deleteToke.bind(this)); //append span contents to tokeSpan tokeSpan.appendChild(tokeLabel); tokeSpan.appendChild(tokeDelete); //return the toke span return tokeSpan } } class emoteList{ constructor(channel){ this.channel = channel this.linkPrompt = document.querySelector('#new-emote-link-input'); this.namePrompt = document.querySelector('#new-emote-name-input'); this.addButton = document.querySelector('#new-emote-button'); this.emoteList = document.querySelector('#emote-list'); //Setup input this.setupInput(); //Pull and render emote list this.updateList(); } setupInput(){ this.addButton.addEventListener('click', this.addEmote.bind(this)); } async deleteEmote(event){ //Strip name from element id const name = event.target.id.replace('emote-list-delete-',''); //Delete emote and pull list const list = await utils.ajax.deleteChanEmote(this.channel, name); //If we received a list if(list != null){ //Pass updated liste to renderEmoteList function instead of pulling it twice this.renderEmoteList(list); } } async addEmote(event){ //Add emote to list and ingest returned updates list const list = await utils.ajax.addChanEmote(this.channel, this.namePrompt.value, this.linkPrompt.value); //If we received a list if(list != null){ //Pass updated liste to renderEmoteList function instead of pulling it twice this.renderEmoteList(list); //Clear out the prompts this.namePrompt.value = ''; this.linkPrompt.value = ''; } } async updateList(){ const list = await utils.ajax.getChanEmotes(this.channel); this.renderEmoteList(list); } renderEmoteList(list){ //Clear the current list this.emoteList.innerHTML = ""; //For each emote in the list list.forEach((emote) => { //Create span to hold emote const emoteDiv = document.createElement('div'); emoteDiv.classList.add('emote-list-emote'); //If the emote is an image if(emote.type == 'image'){ //Create image node var emoteMedia = document.createElement('img'); //if emote is a video }else if(emote.type == 'video'){ //create video node var emoteMedia = document.createElement('video'); //Set video properties emoteMedia.autoplay = true; emoteMedia.muted = true; emoteMedia.controls = false; emoteMedia.loop = true; } //set media link as source emoteMedia.src = emote.link; //Set media class emoteMedia.classList.add('emote-list-media'); //Create title span const titleSpan = document.createElement('span'); titleSpan.classList.add('emote-list-title'); //Create paragraph tag const emoteTitle = document.createElement('p'); //Set title class emoteTitle.classList.add('emote-list-title'); //Set emote title emoteTitle.textContent = utils.unescapeEntities(`[${emote.name}]`); //Create delete icon const deleteIcon = document.createElement('i'); //Set delete icon id and class deleteIcon.classList.add('bi-trash-fill', 'emote-list-delete'); deleteIcon.id = `emote-list-delete-${emote.name}`; //Add delete icon event listener deleteIcon.addEventListener('click',this.deleteEmote.bind(this)); //Add the emote media to the emote span emoteDiv.appendChild(emoteMedia); //Add title paragraph node titleSpan.appendChild(emoteTitle); //Add trash icon node titleSpan.appendChild(deleteIcon); //Add title span emoteDiv.appendChild(titleSpan); //Append the mote span to the emote list this.emoteList.appendChild(emoteDiv); }); } } class deleteBtn{ constructor(channel){ this.channel = channel; this.delete = document.querySelector("#chan-delete"); this.setupInput(); } setupInput(){ this.delete.addEventListener('click', () => {new deleteAccountPopup(this.channel)}); } } class deleteAccountPopup{ constructor(channel){ this.channel = channel; this.popup = new canopyUXUtils.popup("nukeChannel", true, this.asyncConstructor.bind(this)); } asyncConstructor(){ this.prompt = document.querySelector("#delete-channel-popup-prompt"); this.label = document.querySelector("#delete-channel-popup-content"); //Fill channel label this.label.textContent = this.label.textContent.replaceAll("[CHANNEL]", utils.unescapeEntities(this.channel)); this.setupInput(); } setupInput(){ this.prompt.addEventListener("keydown", this.nukeChannel.bind(this)); } async nukeChannel(event){ if(event.key == "Enter"){ if(this.channel === event.target.value){ await utils.ajax.deleteChannel(this.channel, event.target.value); } } } } const channelSettings = new channelSettingsPage();