/*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";
this.descriptionInput.value = this.description.textContent;
//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){
//focus thumbnail input
this.thumbnailInput.focus();
//Remove interactive class from thumby
this.thumbnail.classList.remove('interactive');
}else{
//add interactive class to thumby
this.thumbnail.classList.add('interactive');
}
}
async submitThumbnail(event){
//If we hit didnt hit enter or escape
if(!(event.key == "Enter" || event.key == "Escape") && event.key != null){
//bail!
return;
//Only returns w/ content after this point
}else if(event.key == "Escape" && event.key != null){
//Toggle prompt
this.toggleThumbnailPrompt(false);
return;
}
//Send update off to server and wait for response
const data = await utils.ajax.setChannelThumbnail(this.channel, this.thumbnailInput.value);
//Set new image from updated thumby
this.thumbnail.src = data.thumbnail;
//Toggle prompt
this.toggleThumbnailPrompt(false);
}
toggleDescriptionPrompt(enabled){
if(enabled){
this.description.replaceWith(this.descriptionInput);
this.descriptionInput.focus()
}else{
this.descriptionInput.replaceWith(this.description);
}
}
async 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;
}else if(event.key == "Escape" && event.key != null){
//Toggle prompt
this.toggleDescriptionPrompt(false);
return;
}
//Stop newline from being processed
event.preventDefault();
//Set Description
const data = await utils.ajax.setChannelDescription(this.channel, this.descriptionInput.value);
//Unescape entities from server-side sanatization and safely put the newly made un-safe text inside of the element via .textContent.
//Ensuring sanatized content displays proper, and that any unsanatized content that some how made it through is still safe.
this.description.textContent = utils.unescapeEntities(data.description);
//Toggle prompt
this.toggleDescriptionPrompt(false);
}
}
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.dataset.user;
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.dataset.user = 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.dataset.name;
//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.dataset.name = 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.dataset.key;
//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.dataset.key;
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.dataset.name;
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.dataset.name = 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.dataset.name;
//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.dataset.name = 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();