From c36193633dace5094c9cb050617a18db7452490a Mon Sep 17 00:00:00 2001 From: crowetic Date: Wed, 18 Dec 2024 19:39:15 -0800 Subject: [PATCH] Added AdminBoard with all bugFixes --- assets/js/AdminBoard.js | 756 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 756 insertions(+) create mode 100644 assets/js/AdminBoard.js diff --git a/assets/js/AdminBoard.js b/assets/js/AdminBoard.js new file mode 100644 index 0000000..f94af72 --- /dev/null +++ b/assets/js/AdminBoard.js @@ -0,0 +1,756 @@ +// NOTE - Change isTestMode to false prior to actual release ---- !important - You may also change identifier if you want to not show older cards. +const isEncryptedTestMode = true; +const encryptedCardIdentifierPrefix = "test-MDC"; +let isExistingEncryptedCard = false; +let existingDecryptedCardData = {}; +let existingEncryptedCardIdentifier = {}; +let cardMinterName = {} +let existingCardMinterNames = [] + +console.log("Attempting to load AdminBoard.js"); + +const loadAdminBoardPage = async () => { + // Clear existing content on the page + const bodyChildren = document.body.children; + for (let i = bodyChildren.length - 1; i >= 0; i--) { + const child = bodyChildren[i]; + if (!child.classList.contains("menu")) { + child.remove(); + } + } + + // Add the "Minter Board" content + const mainContent = document.createElement("div"); + mainContent.innerHTML = ` +
+

AdminBoard

+

The Admin Board is an encrypted card publishing board to keep track of minter data for the Minter Admins. Any Admin may publish a card, and related data, make comments on existing cards, and vote on existing card data in support or not of the name on the card. It is essentially a 'project management' tool to assist the Minter Admins in keeping track of the data related to minters they are adding/removing from the minter group.

+

More functionality will be added over time. One of the first features will be the ability to output the existing card data 'decisions', to a json formatted list in order to allow crowetic to run his script easily until the final Mintership proposal changes are completed, and the MINTER group is transferred to 'null'.

+ + +
+ +
+ `; + document.body.appendChild(mainContent); + const publishCardButton = document.getElementById("publish-card-button") + if (publishCardButton) { + publishCardButton.addEventListener("click", async () => { + const publishCardView = document.getElementById("publish-card-view") + publishCardView.style.display = "flex" + document.getElementById("encrypted-cards-container").style.display = "none" + }) + } + const refreshCardsButton = document.getElementById("refresh-cards-button") + if (refreshCardsButton) { + refreshCardsButton.addEventListener("click", async () => { + const encryptedCardsContainer = document.getElementById("encrypted-cards-container") + encryptedCardsContainer.innerHTML = "

Refreshing cards...

" + await fetchAllEncryptedCards() + }) + } + + const cancelPublishButton = document.getElementById("cancel-publish-button") + if (cancelPublishButton) { + cancelPublishButton.addEventListener("click", async () => { + const encryptedCardsContainer = document.getElementById("encrypted-cards-container") + encryptedCardsContainer.style.display = "flex"; // Restore visibility + const publishCardView = document.getElementById("publish-card-view") + publishCardView.style.display = "none"; // Hide the publish form + }) + } + const addLinkButton = document.getElementById("add-link-button") + if (addLinkButton) { + addLinkButton.addEventListener("click", async () => { + const linksContainer = document.getElementById("links-container") + const newLinkInput = document.createElement("input") + newLinkInput.type = "text" + newLinkInput.className = "card-link" + newLinkInput.placeholder = "Enter QDN link" + linksContainer.appendChild(newLinkInput) + }) + } + + document.getElementById("publish-card-form").addEventListener("submit", async (event) => { + event.preventDefault(); + await publishEncryptedCard(); + }); + + await createCardMinterNameList(); + await fetchAllEncryptedCards(); +} + +const extractCardsMinterName = (cardIdentifier) => { + // Ensure the identifier starts with the prefix + if (!cardIdentifier.startsWith(`${encryptedCardIdentifierPrefix}-`)) { + throw new Error('Invalid identifier format or prefix mismatch'); + } + + // Split the identifier into parts + const parts = cardIdentifier.split('-'); + + // Ensure the format has at least 3 parts + if (parts.length < 3) { + throw new Error('Invalid identifier format'); + } + + // Extract minterName (everything from the second part to the second-to-last part) + const minterName = parts.slice(2, -1).join('-'); + + // Return the extracted minterName + return minterName; +} + + +const createCardMinterNameList = async () => { + + const response = await qortalRequest({ + action: "SEARCH_QDN_RESOURCES", + service: "MAIL_PRIVATE", + query: `${encryptedCardIdentifierPrefix}`, + mode: "ALL", + }); + + const validatedEncryptedCards = await Promise.all( + response.map(async card => { + const isValid = await validateEncryptedCardIdentifier(card); + return isValid ? card : null; + }) + ) + + const validEncryptedCards = validatedEncryptedCards.filter(card => card !== null); + + if (validEncryptedCards.length === 0) { + console.log(`no matches found, not adding any names to name list.`) + return; + } + + for (const result of validEncryptedCards) { + const minterName = await extractCardsMinterName(result.identifier) + + if (!existingCardMinterNames.includes(minterName)) { + existingCardMinterNames.push(minterName) + console.log(`cardsMinterName: ${minterName} - added to list`) + } + } +}; + +//Main function to load the Minter Cards ---------------------------------------- +const fetchAllEncryptedCards = async () => { + const encryptedCardsContainer = document.getElementById("encrypted-cards-container"); + encryptedCardsContainer.innerHTML = "

Loading cards...

"; + + try { + const response = await qortalRequest({ + action: "SEARCH_QDN_RESOURCES", + service: "MAIL_PRIVATE", + query: encryptedCardIdentifierPrefix, + mode: "ALL" + }); + + if (!response || !Array.isArray(response) || response.length === 0) { + encryptedCardsContainer.innerHTML = "

No cards found.

"; + return; + } + + // Validate cards and filter + const validatedEncryptedCards = await Promise.all( + response.map(async card => { + const isValid = await validateEncryptedCardIdentifier(card); + return isValid ? card : null; + }) + ); + + const validEncryptedCards = validatedEncryptedCards.filter(card => card !== null); + + if (validEncryptedCards.length === 0) { + encryptedCardsContainer.innerHTML = "

No valid cards found.

"; + return; + } + + // Group by identifier and keep only the newest card for each identifier + const latestCardsMap = new Map(); + + validEncryptedCards.forEach(card => { + const timestamp = card.updated || card.created || 0; + const existingCard = latestCardsMap.get(card.identifier); + + if (!existingCard || timestamp > (existingCard.updated || existingCard.created || 0)) { + latestCardsMap.set(card.identifier, card); + } + }); + + // Extract unique cards and sort by timestamp descending + const uniqueValidCards = Array.from(latestCardsMap.values()).sort((a, b) => { + const timestampA = a.updated || a.created || 0; + const timestampB = b.updated || b.created || 0; + return timestampB - timestampA; + }); + + // Display skeleton cards immediately + encryptedCardsContainer.innerHTML = ""; + uniqueValidCards.forEach(card => { + const skeletonHTML = createSkeletonCardHTML(card.identifier); + encryptedCardsContainer.insertAdjacentHTML("beforeend", skeletonHTML); + }); + + // Fetch and update each card + uniqueValidCards.forEach(async card => { + try { + const cardDataResponse = await qortalRequest({ + action: "FETCH_QDN_RESOURCE", + name: card.name, + service: "MAIL_PRIVATE", + identifier: card.identifier, + encoding: "base64" + }); + + if (!cardDataResponse) { + console.warn(`Skipping invalid card: ${JSON.stringify(card)}`); + removeSkeleton(card.identifier); + return; + } + + const decryptedCardData = await decryptAndParseObject(cardDataResponse); + + // Skip cards without polls + if (!decryptedCardData.poll) { + console.warn(`Skipping card with no poll: ${card.identifier}`); + removeSkeleton(card.identifier); + return; + } + + // Fetch poll results + const pollResults = await fetchPollResults(decryptedCardData.poll); + const minterNameFromIdentifier = await extractCardsMinterName(card.identifier); + + // Generate final card HTML + const finalCardHTML = await createEncryptedCardHTML(decryptedCardData, pollResults, card.identifier); + replaceEncryptedSkeleton(card.identifier, finalCardHTML); + } catch (error) { + console.error(`Error processing card ${card.identifier}:`, error); + removeEncryptedSkeleton(card.identifier); // Silently remove skeleton on error + } + }); + + } catch (error) { + console.error("Error loading cards:", error); + encryptedCardsContainer.innerHTML = "

Failed to load cards.

"; + } +}; + + +const removeEncryptedSkeleton = (cardIdentifier) => { + const encryptedSkeletonCard = document.getElementById(`skeleton-${cardIdentifier}`); + if (encryptedSkeletonCard) { + encryptedSkeletonCard.remove(); // Remove the skeleton silently + } +}; + +const replaceEncryptedSkeleton = (cardIdentifier, htmlContent) => { + const encryptedSkeletonCard = document.getElementById(`skeleton-${cardIdentifier}`); + if (encryptedSkeletonCard) { + encryptedSkeletonCard.outerHTML = htmlContent; + } +}; + +// Function to create a skeleton card +const createEncryptedSkeletonCardHTML = (cardIdentifier) => { + return ` +
+
+
+
+
+
+
+
+
+
+
+
+ `; +}; + + +// Function to check and fech an existing Minter Card if attempting to publish twice ---------------------------------------- +const fetchExistingEncryptedCard = async (minterName) => { + try { + // Step 1: Perform the search + const response = await qortalRequest({ + action: "SEARCH_QDN_RESOURCES", + service: "MAIL_PRIVATE", + identifier: encryptedCardIdentifierPrefix, + query: minterName, + mode: "ALL", + }); + + console.log(`SEARCH_QDN_RESOURCES response: ${JSON.stringify(response, null, 2)}`); + + // Step 2: Check if the response is an array and not empty + if (!response || !Array.isArray(response) || response.length === 0) { + console.log("No cards found for the current user."); + return null; + } + + // Step 3: Validate cards asynchronously + const validatedCards = await Promise.all( + response.map(async card => { + const isValid = await validateEncryptedCardIdentifier(card) + return isValid ? card : null; + }) + ); + + // Step 4: Filter out invalid cards + const validCards = validatedCards.filter(card => card !== null); + + if (validCards.length > 0) { + // Step 5: Sort by most recent timestamp + const mostRecentCard = validCards.sort((a, b) => b.created - a.created)[0]; + + // Step 6: Fetch full card data + const cardDataResponse = await qortalRequest({ + action: "FETCH_QDN_RESOURCE", + name: mostRecentCard.name, + service: mostRecentCard.service, + identifier: mostRecentCard.identifier, + encoding: "base64" + }); + + existingEncryptedCardIdentifier = mostRecentCard.identifier; + + existingDecryptedCardData = await decryptAndParseObject(cardDataResponse) + console.log("Full card data fetched successfully:", existingDecryptedCardData); + + return existingDecryptedCardData; + } + + console.log("No valid cards found."); + return null; + } catch (error) { + console.error("Error fetching existing card:", error); + return null; + } +}; + +// Validate that a card is indeed a card and not a comment. ------------------------------------- +const validateEncryptedCardIdentifier = async (card) => { + return ( + typeof card === "object" && + card.name && + card.service === "MAIL_PRIVATE" && + card.identifier && !card.identifier.includes("comment") && + card.created + ); +} + +// Load existing card data passed, into the form for editing ------------------------------------- +const loadEncryptedCardIntoForm = async () => { + if (existingDecryptedCardData) { + console.log("Loading existing card data:", existingDecryptedCardData); + document.getElementById("minter-name-input").value = existingDecryptedCardData.minterName + document.getElementById("card-header").value = existingDecryptedCardData.header + document.getElementById("card-content").value = existingDecryptedCardData.content + + const linksContainer = document.getElementById("links-container"); + linksContainer.innerHTML = ""; // Clear previous links + existingDecryptedCardData.links.forEach(link => { + const linkInput = document.createElement("input"); + linkInput.type = "text"; + linkInput.className = "card-link"; + linkInput.value = link; + linksContainer.appendChild(linkInput); + }); + } +} + +const validateMinterName = async(minterName) => { + try { + const nameInfo = await getNameInfo(minterName) + if (!nameInfo) { + return error (`No NameInfo able to be obtained? Did you pass name?`) + } + const name = nameInfo.name + return name + } catch (error){ + console.error(`extracting name from name info: ${minterName} failed.`, error) + } +} + +// Main function to publish a new Minter Card ----------------------------------------------- +const publishEncryptedCard = async () => { + const minterNameInput = document.getElementById("minter-name-input").value.trim(); + const header = document.getElementById("card-header").value.trim(); + const content = document.getElementById("card-content").value.trim(); + const links = Array.from(document.querySelectorAll(".card-link")) + .map(input => input.value.trim()) + .filter(link => link.startsWith("qortal://")); + const publishedMinterName = await validateMinterName(minterNameInput) + + if (!header || !content) { + alert("Header and Content are required!"); + return; + } + + if (!publishedMinterName) { + alert(`Minter name invalid! Name input: ${minterNameInput} - please check the name and try again!`) + return; + } + + if (!isExistingEncryptedCard) { + if (existingCardMinterNames.includes(publishedMinterName)) { + const updateCard = confirm(`Minter Name: ${publishedMinterName} - CARD ALREADY EXISTS, you can update it (overwriting existing publish) or cancel... `) + if (updateCard) { + await fetchExistingEncryptedCard(publishedMinterName) + await loadEncryptedCardIntoForm() + isExistingEncryptedCard = true + return + }else { + return; + } + } + } + + const cardIdentifier = isExistingEncryptedCard ? existingEncryptedCardIdentifier : `${encryptedCardIdentifierPrefix}-${publishedMinterName}-${await uid()}`; + const pollName = `${cardIdentifier}-poll`; + const pollDescription = `Admin Board Poll Published By ${userState.accountName}`; + + const cardData = { + minterName: `${publishedMinterName}`, + header, + content, + links, + creator: userState.accountName, + timestamp: Date.now(), + poll: pollName, + }; + + try { + + let base64CardData = await objectToBase64(cardData); + if (!base64CardData) { + console.log(`initial base64 object creation with objectToBase64 failed, using btoa...`); + base64CardData = btoa(JSON.stringify(cardData)); + } + + + const verifiedAdminPublicKeys = await fetchAdminGroupsMembersPublicKeys() + adminPublicKeys = verifiedAdminPublicKeys + + await qortalRequest({ + action: "PUBLISH_QDN_RESOURCE", + name: userState.accountName, + service: "MAIL_PRIVATE", + identifier: cardIdentifier, + data64: base64CardData, + encrypt: true, + publicKeys: verifiedAdminPublicKeys + }); + + if (!isExistingEncryptedCard){ + await qortalRequest({ + action: "CREATE_POLL", + pollName, + pollDescription, + pollOptions: ['Yes, No'], + pollOwnerAddress: userState.accountAddress, + }); + + alert("Card and poll published successfully!"); + existingCardMinterNames.push(`${publishedMinterName}`) + } + + if (isExistingEncryptedCard){ + alert("Card Updated Successfully! (No poll updates are possible at this time...)") + } + document.getElementById("publish-card-form").reset(); + document.getElementById("publish-card-view").style.display = "none"; + document.getElementById("encrypted-cards-container").style.display = "flex"; + } catch (error) { + console.error("Error publishing card or poll:", error); + alert("Failed to publish card and poll."); + } +} + +// Post a comment on a card. --------------------------------- +const postEncryptedComment = async (cardIdentifier) => { + const commentInput = document.getElementById(`new-comment-${cardIdentifier}`); + const commentText = commentInput.value.trim(); + if (!commentText) { + alert('Comment cannot be empty!'); + return; + } + + const postTimestamp = `${Date.now()}` + console.log(`timestmp to be posted: ${postTimestamp}`) + + const commentData = { + content: commentText, + creator: userState.accountName, + timestamp: postTimestamp, + }; + + const commentIdentifier = `comment-${cardIdentifier}-${await uid()}`; + + if (!Array.isArray(adminPublicKeys) || (adminPublicKeys.length === 0)) { + const verifiedAdminPublicKeys = await fetchAdminGroupsMembersPublicKeys() + adminPublicKeys = verifiedAdminPublicKeys + } + + + try { + const base64CommentData = await objectToBase64(commentData); + if (!base64CommentData) { + console.log(`initial base64 object creation with objectToBase64 failed, using btoa...`); + base64CommentData = btoa(JSON.stringify(commentData)); + } + + await qortalRequest({ + action: "PUBLISH_QDN_RESOURCE", + name: userState.accountName, + service: "MAIL_PRIVATE", + identifier: commentIdentifier, + data64: base64CommentData, + encrypt: true, + publicKeys: adminPublicKeys + }); + + alert('Comment posted successfully!'); + commentInput.value = ''; // Clear input + } catch (error) { + console.error('Error posting comment:', error); + alert('Failed to post comment.'); + } +}; + +//Fetch the comments for a card with passed card identifier ---------------------------- +const fetchEncryptedComments = async (cardIdentifier) => { + try { + const response = await qortalRequest({ + action: 'SEARCH_QDN_RESOURCES', + service: 'MAIL_PRIVATE', + query: `comment-${cardIdentifier}`, + mode: "ALL" + }); + + return response; + } catch (error) { + console.error(`Error fetching comments for ${cardIdentifier}:`, error); + return []; + } +}; + +// display the comments on the card, with passed cardIdentifier to identify the card -------------- +const displayEncryptedComments = async (cardIdentifier) => { + try { + const comments = await fetchEncryptedComments(cardIdentifier); + const commentsContainer = document.getElementById(`comments-container-${cardIdentifier}`); + + // Fetch and display each comment + for (const comment of comments) { + const commentDataResponse = await qortalRequest({ + action: "FETCH_QDN_RESOURCE", + name: comment.name, + service: "MAIL_PRIVATE", + identifier: comment.identifier, + encoding: "base64" + }); + + const decryptedCommentData = await decryptAndParseObject(commentDataResponse) + + const timestamp = await timestampToHumanReadableDate(decryptedCommentData.timestamp); + + //TODO - add fetching of poll results and checking to see if the commenter has voted and display it as 'supports minter' section. + const commentHTML = ` +
+

${decryptedCommentData.creator}:

+

${decryptedCommentData.content}

+

${timestamp}

+
+ `; + commentsContainer.insertAdjacentHTML('beforeend', commentHTML); + } + } catch (error) { + console.error(`Error displaying comments for ${cardIdentifier}:`, error); + alert("Failed to load comments. Please try again."); + } +}; + +const calculateAdminBoardPollResults = async (pollData, minterGroupMembers, minterAdmins) => { + const memberAddresses = minterGroupMembers.map(member => member.member) + const minterAdminAddresses = minterAdmins.map(member => member.member) + const adminGroupsMembers = await fetchAllAdminGroupsMembers() + const groupAdminAddresses = adminGroupsMembers.map(member => member.member) + const adminAddresses = []; + adminAddresses.push(...minterAdminAddresses,...groupAdminAddresses); + + let adminYes = 0, adminNo = 0, minterYes = 0, minterNo = 0, yesWeight = 0 , noWeight = 0 + + pollData.voteWeights.forEach(weightData => { + if (weightData.optionName === 'Yes') { + yesWeight = weightData.voteWeight + } else if (weightData.optionName === 'No') { + noWeight = weightData.voteWeight + } + }) + + for (const vote of pollData.votes) { + const voterAddress = await getAddressFromPublicKey(vote.voterPublicKey) + console.log(`voter address: ${voterAddress}`) + + if (vote.optionIndex === 0) { + adminAddresses.includes(voterAddress) ? adminYes++ : memberAddresses.includes(voterAddress) ? minterYes++ : console.log(`voter ${voterAddress} is not a minter nor an admin...Not including results...`) + } else if (vote.optionIndex === 1) { + adminAddresses.includes(voterAddress) ? adminNo++ : memberAddresses.includes(voterAddress) ? minterNo++ : console.log(`voter ${voterAddress} is not a minter nor an admin...Not including results...`) + } + } + + // TODO - create a new function to calculate the weights of each voting MINTER only. + // This will give ALL weight whether voter is in minter group or not... + // until that is changed on the core we must calculate manually. + const totalYesWeight = yesWeight + const totalNoWeight = noWeight + + const totalYes = adminYes + minterYes + const totalNo = adminNo + minterNo + + return { adminYes, adminNo, minterYes, minterNo, totalYes, totalNo, totalYesWeight, totalNoWeight } +} + +const toggleEncryptedComments = async (cardIdentifier) => { + const commentsSection = document.getElementById(`comments-section-${cardIdentifier}`); + if (commentsSection.style.display === 'none' || !commentsSection.style.display) { + await displayEncryptedComments(cardIdentifier); + commentsSection.style.display = 'block'; + } else { + commentsSection.style.display = 'none'; + } +}; + +const createLinkDisplayModal = async () => { + const modalHTML = ` + + `; + document.body.insertAdjacentHTML('beforeend', modalHTML); +} + +// Function to open the modal +const openLinkDisplayModal = async (link) => { + const processedLink = await processQortalLinkForRendering(link) // Process the link to replace `qortal://` for rendering in modal + const modal = document.getElementById('modal'); + const modalContent = document.getElementById('modalContent'); + modalContent.src = processedLink; // Set the iframe source to the link + modal.style.display = 'block'; // Show the modal +} + +// Function to close the modal +const closeLinkDisplayModal = async () => { + const modal = document.getElementById('modal'); + const modalContent = document.getElementById('modalContent'); + modal.style.display = 'none'; // Hide the modal + modalContent.src = ''; // Clear the iframe source +} + +const processQortalLinkForRendering = async (link) => { + if (link.startsWith('qortal://')) { + const match = link.match(/^qortal:\/\/([^/]+)(\/.*)?$/); + if (match) { + const firstParam = match[1].toUpperCase(); // Convert to uppercase + const remainingPath = match[2] || ""; // Rest of the URL + // Perform any asynchronous operation if necessary + await new Promise(resolve => setTimeout(resolve, 10)); // Simulating async operation + return `/render/${firstParam}${remainingPath}`; + } + } + return link; // Return unchanged if not a Qortal link +} + +// Create the overall Minter Card HTML ----------------------------------------------- +const createEncryptedCardHTML = async (cardData, pollResults, cardIdentifier) => { + const { minterName, header, content, links, creator, timestamp, poll } = cardData; + const formattedDate = new Date(timestamp).toLocaleString(); + const minterAvatar = `/arbitrary/THUMBNAIL/${minterName}/qortal_avatar`; + const creatorAvatar = `/arbitrary/THUMBNAIL/${creator}/qortal_avatar`; + const linksHTML = links.map((link, index) => ` + + `).join(""); + + const minterGroupMembers = await fetchMinterGroupMembers(); + const minterAdmins = await fetchMinterGroupAdmins(); + const { adminYes = 0, adminNo = 0, minterYes = 0, minterNo = 0, totalYes = 0, totalNo = 0, totalYesWeight = 0, totalNoWeight = 0 } = await calculateAdminBoardPollResults(pollResults, minterGroupMembers, minterAdmins) + await createModal() + return ` +
+
+

Posted By:

+ User Avatar +

${creator}

+
Regarding Minter:
+ User Avatar +

${minterName}

+

${header}

+
+
+ ${content} +
+
Informational Links:
+ +
Resulting Support:
+
+
+ Admin Yes: ${adminYes} + Admin No: ${adminNo} +
+
+ TBD ${minterYes} + TBD ${minterNo} +
+
+ Total Yes: ${totalYes} + Total No: ${totalNo} +
+
+
Support ${minterName}?
+
+
+ + + +
+
+ +

Published by: ${creator} on ${formattedDate}

+
+ `; +} +