// // NOTE - Change isTestMode to false prior to actual release ---- !important - You may also change identifier if you want to not show older cards. const testMode = false const minterCardIdentifierPrefix = "Minter-board-card" let isExistingCard = false let existingCardData = {} let existingCardIdentifier = {} const MIN_ADMIN_YES_VOTES = 9; const GROUP_APPROVAL_FEATURE_TRIGGER_HEIGHT = 2012800 //TODO update this to correct featureTrigger height when known, either that, or pull from core. let featureTriggerPassed = false let isApproved = false const loadMinterBoardPage = 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") const publishButtonColor = '#527c9d' const minterBoardNameColor = '#527c9d' mainContent.innerHTML = ` <div class="minter-board-main" style="padding: 20px; text-align: center;"> <h1 style="color: ${minterBoardNameColor};">Minter Board</h1> <p style="font-size: 1.25em;"> Publish a Minter Card with Information, and obtain and view the support of the community. Welcome to the Minter Board!</p> <button id="publish-card-button" class="publish-card-button" style="margin: 20px; padding: 10px; background-color: ${publishButtonColor}">Publish Minter Card</button> <button id="refresh-cards-button" class="refresh-cards-button" style="padding: 10px;">Refresh Cards</button> <select id="sort-select" style="margin-left: 10px; padding: 5px; font-size: 1.25rem; color:rgb(38, 106, 106); background-color: black;"> <option value="newest" selected>Sort by Date</option> <option value="name">Sort by Name</option> <option value="recent-comments">Newest Comments</option> <option value="least-votes">Least Votes</option> <option value="most-votes">Most Votes</option> </select> <span id="board-card-counter" style="font-size: 1rem; color: #999ccc; padding: 0.5em;"></span> <select id="time-range-select" style="margin-left: 10px; padding: 5px; font-size: 1.25rem; color: white; background-color: black;"> <option value="0">Show All</option> <option value="1">Last 1 day</option> <option value="7">Last 7 days</option> <option value="30" selected>Last 30 days</option> <option value="90">Last 90 days</option> </select> <label style="color:rgb(181, 181, 181); margin-left: 10px;"> <input type="checkbox" id="show-existing-checkbox" /> Show Existing Minter Cards (history) </label> <div id="cards-container" class="cards-container" style="margin-top: 20px;"></div> <div id="publish-card-view" class="publish-card-view" style="display: none; text-align: left; padding: 20px;"> <form id="publish-card-form" class="publish-card-form"> <h3>Create or Update Your Card</h3> <label for="card-header">Header:</label> <input type="text" id="card-header" maxlength="100" placeholder="Enter card header" required> <label for="card-content">Content:</label> <textarea id="card-content" placeholder="Enter detailed information about why you would like to be a minter... the more the better, and links to things you have published on QDN will help a lot! Give the Minter Admins things to make decisions by!" required></textarea> <label for="card-links">Links (qortal://...):</label> <div id="links-container"> <input type="text" class="card-link" placeholder="Enter QDN link"> </div> <button type="button" id="add-link-button">Add Another Link</button> <button type="submit" id="submit-publish-button">Publish Card</button> <button type="button" id="cancel-publish-button">Cancel</button> </form> </div> </div> ` document.body.appendChild(mainContent) createScrollToTopButton() document.getElementById("publish-card-button").addEventListener("click", async () => { try { const fetchedCard = await fetchExistingCard(minterCardIdentifierPrefix) if (fetchedCard) { // An existing card is found if (testMode) { // In test mode, ask user what to do const updateCard = confirm("A card already exists. Do you want to update it?") if (updateCard) { isExistingCard = true await loadCardIntoForm(existingCardData) alert("Edit your existing card and publish.") } else { alert("Test mode: You can now create a new card.") isExistingCard = false existingCardData = {} document.getElementById("publish-card-form").reset() } } else { // Not in test mode, force editing alert("A card already exists. Publishing of multiple cards is not allowed. Please update your card."); isExistingCard = true; await loadCardIntoForm(existingCardData) } } else { // No existing card found console.log("No existing card found. Creating a new card.") isExistingCard = false } // Show the form const publishCardView = document.getElementById("publish-card-view") publishCardView.style.display = "flex" document.getElementById("cards-container").style.display = "none" } catch (error) { console.error("Error checking for existing card:", error) alert("Failed to check for existing card. Please try again.") } }) document.getElementById("refresh-cards-button").addEventListener("click", async () => { const cardsContainer = document.getElementById("cards-container") cardsContainer.innerHTML = "<p>Refreshing cards...</p>" await loadCards(minterCardIdentifierPrefix) }) document.getElementById("cancel-publish-button").addEventListener("click", async () => { const cardsContainer = document.getElementById("cards-container") cardsContainer.style.display = "flex"; // Restore visibility const publishCardView = document.getElementById("publish-card-view") publishCardView.style.display = "none"; // Hide the publish form }) document.getElementById("add-link-button").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 publishCard(minterCardIdentifierPrefix) }) document.getElementById("time-range-select").addEventListener("change", async () => { // Re-load the cards whenever user chooses a new sort option. await loadCards(minterCardIdentifierPrefix) }) document.getElementById("sort-select").addEventListener("change", async () => { // Re-load the cards whenever user chooses a new sort option. await loadCards(minterCardIdentifierPrefix) }) const showExistingCardsCheckbox = document.getElementById('show-existing-checkbox') if (showExistingCardsCheckbox) { showExistingCardsCheckbox.addEventListener('change', async (event) => { await loadCards(minterCardIdentifierPrefix) }) } await featureTriggerCheck() await loadCards(minterCardIdentifierPrefix) } const extractMinterCardsMinterName = async (cardIdentifier) => { // Ensure the identifier starts with the prefix if ((!cardIdentifier.startsWith(minterCardIdentifierPrefix)) && (!cardIdentifier.startsWith(addRemoveIdentifierPrefix))) { throw new Error('minterCard does not match identifier check') } // 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') } try { if (cardIdentifier.startsWith(minterCardIdentifierPrefix)){ const searchSimpleResults = await searchSimple('BLOG_POST', `${cardIdentifier}`, '', 1, 0, '', false, true) const minterName = await searchSimpleResults.name return minterName } else if (cardIdentifier.startsWith(addRemoveIdentifierPrefix)) { const searchSimpleResults = await searchSimple('BLOG_POST', `${cardIdentifier}`, '', 1, 0, '', false, true) const publisherName = searchSimpleResults.name const cardDataResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: publisherName, service: "BLOG_POST", identifier: cardIdentifier, }) let nameInvalid = false const minterName = cardDataResponse.minterName if (minterName){ return minterName } else { nameInvalid = true console.warn(`fuckery detected on identifier: ${cardIdentifier}, hello dipshit Mythril!, name invalid? Name doesn't match publisher? Returning invalid flag + publisherName...`) return publisherName } } } catch (error) { throw error } } const groupAndLabelByIdentifier = (allCards) => { // Group by identifier const mapById = new Map() allCards.forEach(card => { if (!mapById.has(card.identifier)) { mapById.set(card.identifier, []) } mapById.get(card.identifier).push(card) }) // For each identifier's group, sort oldest->newest so the first is "master" const output = [] for (const [identifier, group] of mapById.entries()) { group.sort((a, b) => { const aTime = a.created || 0 const bTime = b.created || 0 return aTime - bTime // oldest first }) // Mark the first as master group[0].isMaster = true // The rest are updates for (let i = 1; i < group.length; i++) { group[i].isMaster = false } // push them all to output output.push(...group) } return output } const groupByIdentifierOldestFirst = (allCards) => { // map of identifier => array of cards const mapById = new Map() allCards.forEach(card => { if (!mapById.has(card.identifier)) { mapById.set(card.identifier, []) } mapById.get(card.identifier).push(card) }) // sort each group oldest->newest for (const [identifier, group] of mapById.entries()) { group.sort((a, b) => { const aTime = a.created || 0 const bTime = b.created || 0 return aTime - bTime // oldest first }) } return mapById } const buildMinterNameGroups = async (mapById) => { // We'll build an array of objects: { minterName, cards } // Then we can combine any that share the same minterName. const nameGroups = [] for (let [identifier, group] of mapById.entries()) { // group[0] is the oldest => "master" card let masterCard = group[0] // Filter out any cards that are not published by the 'masterPublisher' const masterPublisherName = masterCard.name // Remove any cards in this identifier group that have a different publisherName const filteredGroup = group.filter(c => c.name === masterPublisherName) // If filtering left zero cards, skip entire group if (!filteredGroup.length) { console.warn(`All cards removed for identifier=${identifier} (different publishers). Skipping.`) continue } // Reassign group to the filtered version, then re-define masterCard group = filteredGroup masterCard = group[0] // oldest after filtering // attempt to obtain minterName from the master card let masterMinterName try { masterMinterName = await extractMinterCardsMinterName(masterCard.identifier) } catch (err) { console.warn(`Skipping entire group ${identifier}, no valid minterName from master`, err) continue } // Store an object with the minterName we extracted, plus all cards in that group nameGroups.push({ minterName: masterMinterName, cards: group // includes the master & updates }) } // Combine them: minterName => array of *all* cards from all matching groups const combinedMap = new Map() for (const entry of nameGroups) { const mName = entry.minterName if (!combinedMap.has(mName)) { combinedMap.set(mName, []) } combinedMap.get(mName).push(...entry.cards) } return combinedMap } const getNewestCardPerMinterName = (combinedMap) => { // We'll produce an array of the newest card for each minterName, this will be utilized as the 'final filter' to display cards published/updated by unique minters. const finalOutput = [] for (const [mName, cardArray] of combinedMap.entries()) { // sort by updated or created, descending => newest first cardArray.sort((a, b) => { const aTime = a.updated || a.created || 0 const bTime = b.updated || b.created || 0 return bTime - aTime }) // newest is [0] finalOutput.push(cardArray[0]) } // Then maybe globally sort them newest first finalOutput.sort((a, b) => { const aTime = a.updated || a.created || 0 const bTime = b.updated || b.created || 0 return bTime - aTime }) return finalOutput } const processMinterBoardCards = async (allValidCards) => { // group by identifier, sorted oldest->newest const mapById = groupByIdentifierOldestFirst(allValidCards) // build a map of minterName => all cards from those identifiers const minterNameMap = await buildMinterNameGroups(mapById) // from that map, keep only the single newest card per minterName const newestCards = getNewestCardPerMinterName(minterNameMap) // return final array of all newest cards return newestCards } const processARBoardCards = async (allValidCards) => { const mapById = groupByIdentifierOldestFirst(allValidCards) // build a map of minterName => all cards from those identifiers const mapByName = await buildMinterNameGroups(mapById) // For each minterName group, we might want to sort them newest->oldest const finalOutput = [] for (const [minterName, group] of mapByName.entries()) { group.sort((a, b) => { const aTime = a.updated || a.created || 0 const bTime = b.updated || b.created || 0 return bTime - aTime }) // both resolution for the duplicate QuickMythril card, and handling of all future duplicates that may be published... if (group[0].identifier === 'QM-AR-card-Xw3dxL') { console.warn(`This is a bug that allowed a duplicate prior to the logic displaying them based on original publisher only... displaying in reverse order...`) group[0].isDuplicate = true for (let i = 1; i < group.length; i++) { group[i].isDuplicate = false } }else { group[0].isDuplicate = false for (let i = 1; i < group.length; i++) { group[i].isDuplicate = true } } // push them all finalOutput.push(...group) } // Sort final by newest overall finalOutput.sort((a, b) => { const aTime = a.updated || a.created || 0 const bTime = b.updated || b.created || 0 return bTime - aTime }) return finalOutput } //Main function to load the Minter Cards ---------------------------------------- const loadCards = async (cardIdentifierPrefix) => { const cardsContainer = document.getElementById("cards-container") let isARBoard = false cardsContainer.innerHTML = "<p>Loading cards...</p>" const counterSpan = document.getElementById("board-card-counter") if (counterSpan) { // Clear or show "Loading..." counterSpan.textContent = "(loading...)" } if (cardIdentifierPrefix.startsWith("QM-AR-card")) { isARBoard = true console.warn(`ARBoard determined:`, isARBoard) } let afterTime = 0 const timeRangeSelect = document.getElementById("time-range-select") const showExistingCheckbox = document.getElementById("show-existing-checkbox") const showExisting = showExistingCheckbox && showExistingCheckbox.checked if (timeRangeSelect) { const days = parseInt(timeRangeSelect.value, 10) if (days > 0) { const now = Date.now() const dayMs = 24 * 60 * 60 * 1000 afterTime = now - days * dayMs // e.g. last X days console.log(`afterTime for last ${days} days = ${new Date(afterTime).toLocaleString()}`) } } try { // 1) Fetch raw "BLOG_POST" entries const response = await searchSimple('BLOG_POST', cardIdentifierPrefix, '', 0, 0, '', false, true, afterTime) if (!response || !Array.isArray(response) || response.length === 0) { cardsContainer.innerHTML = "<p>No cards found.</p>" return } // 2) Validate structure const validatedCards = await Promise.all( response.map(async (card) => { const isValid = await validateCardStructure(card) return isValid ? card : null }) ) const validCards = validatedCards.filter((card) => card !== null) if (validCards.length === 0) { cardsContainer.innerHTML = "<p>No valid cards found.</p>" return } // Additional logic for ARBoard or MinterCards const finalCards = isARBoard ? await processARBoardCards(validCards) : await processMinterBoardCards(validCards) // Sort finalCards according to selectedSort let selectedSort = 'newest' const sortSelect = document.getElementById('sort-select') if (sortSelect) { selectedSort = sortSelect.value } if (selectedSort === 'name') { finalCards.sort((a, b) => { const nameA = a.name?.toLowerCase() || '' const nameB = b.name?.toLowerCase() || '' return nameA.localeCompare(nameB) }) } else if (selectedSort === 'recent-comments') { // If you need the newest comment timestamp for (let card of finalCards) { card.newestCommentTimestamp = await getNewestCommentTimestamp(card.identifier) } finalCards.sort((a, b) => (b.newestCommentTimestamp || 0) - (a.newestCommentTimestamp || 0) ) } else if (selectedSort === 'least-votes') { await applyVoteSortingData(finalCards, /* ascending= */ true) } else if (selectedSort === 'most-votes') { await applyVoteSortingData(finalCards, /* ascending= */ false) } // else 'newest' => do nothing (already sorted newest-first by your process functions). // Create the 'finalCardsArray' that includes the data, etc. let finalCardsArray = [] let alreadyMinterCards = [] cardsContainer.innerHTML = '' for (const card of finalCards) { try { const skeletonHTML = createSkeletonCardHTML(card.identifier) cardsContainer.insertAdjacentHTML("beforeend", skeletonHTML) const cardDataResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: card.name, service: "BLOG_POST", identifier: card.identifier }) if (!cardDataResponse || !cardDataResponse.poll) { // skip console.warn(`Skipping card: missing data/poll. identifier=${card.identifier}`) removeSkeleton(card.identifier) continue } // Extra validation: check poll ownership matches card publisher const pollPublisherAddress = await getPollOwnerAddress(cardDataResponse.poll) const cardPublisherAddress = await fetchOwnerAddressFromName(card.name) if (pollPublisherAddress !== cardPublisherAddress) { console.warn(`Poll hijack attack found, discarding card ${card.identifier}`) removeSkeleton(card.identifier) continue } // If ARBoard, do a quick address check if (isARBoard) { const ok = await verifyMinter(cardDataResponse.minterName) if (!ok) { console.warn(`Card is not a minter nor an admin, not including in ARBoard. identifier: ${card.identifier}`) removeSkeleton(card.identifier) continue } } else { const isAlreadyMinter = await verifyMinter(cardDataResponse.creator) if (isAlreadyMinter) { console.warn(`card IS ALREADY a minter, adding to alreadyMinterCards array: ${card.identifier}`) removeSkeleton(card.identifier) alreadyMinterCards.push({ ...card, cardDataResponse, pollPublisherAddress, cardPublisherAddress }) continue } } // **Push** to finalCardsArray for further processing (duplicates, etc.) finalCardsArray.push({ ...card, cardDataResponse, pollPublisherAddress, cardPublisherAddress, }) if (counterSpan) { const displayedCount = finalCardsArray.length const alreadyMinterCount = alreadyMinterCards.length // If you want to show both counterSpan.textContent = `(${displayedCount} cards, ${alreadyMinterCount} existingMinters)` } } catch (err) { console.error(`Error preparing card ${card.identifier}`, err) removeSkeleton(card.identifier) } } // Next, do the actual rendering: // cardsContainer.innerHTML = "" for (const cardObj of finalCardsArray) { // Insert a skeleton first if you like // const skeletonHTML = createSkeletonCardHTML(cardObj.identifier) // cardsContainer.insertAdjacentHTML("beforeend", skeletonHTML) // Build final HTML const pollResults = await fetchPollResults(cardObj.cardDataResponse.poll) const commentCount = await countComments(cardObj.identifier) const cardUpdatedTime = cardObj.updated || null const bgColor = generateDarkPastelBackgroundBy(cardObj.name) // Construct the final HTML for each card const finalCardHTML = isARBoard ? await createARCardHTML( cardObj.cardDataResponse, pollResults, cardObj.identifier, commentCount, cardUpdatedTime, bgColor, cardObj.cardPublisherAddress, cardObj.isDuplicate ) : await createCardHTML( cardObj.cardDataResponse, pollResults, cardObj.identifier, commentCount, cardUpdatedTime, bgColor, cardObj.cardPublisherAddress ) replaceSkeleton(cardObj.identifier, finalCardHTML) } if (showExisting && alreadyMinterCards.length > 0) { console.warn(`Rendering Existing Minter cards because user selected showExisting`) for (const mintedCardObj of alreadyMinterCards) { const skeletonHTML = createSkeletonCardHTML(mintedCardObj.identifier) cardsContainer.insertAdjacentHTML("beforeend", skeletonHTML) const pollResults = await fetchPollResults(mintedCardObj.cardDataResponse.poll) const commentCount = await countComments(mintedCardObj.identifier) const cardUpdatedTime = mintedCardObj.updated || null const bgColor = generateDarkPastelBackgroundBy(mintedCardObj.name) const isExistingMinter = true const finalCardHTML = await createCardHTML( mintedCardObj.cardDataResponse, pollResults, mintedCardObj.identifier, commentCount, cardUpdatedTime, bgColor, mintedCardObj.cardPublisherAddress, isExistingMinter ) replaceSkeleton(mintedCardObj.identifier, finalCardHTML) } } } catch (error) { console.error("Error loading cards:", error) cardsContainer.innerHTML = "<p>Failed to load cards.</p>" if (counterSpan) { counterSpan.textContent = "(error loading)" } } } const verifyMinter = async (minterName) => { try { const nameInfo = await getNameInfo(minterName) if (!nameInfo) return false const minterAddress = nameInfo.owner const isValid = await getAddressInfo(minterAddress) if (!isValid) return false // Then check if they're in the minter group const minterGroup = await fetchMinterGroupMembers() const adminGroup = await fetchMinterGroupAdmins() const minterGroupAddresses = minterGroup.map(m => m.member) const adminGroupAddresses = adminGroup.map(m => m.member) return (minterGroupAddresses.includes(minterAddress) || adminGroupAddresses.includes(minterAddress)) } catch (err) { console.warn("verifyMinter error:", err) return false } } const applyVoteSortingData = async (cards, ascending = true) => { const minterGroupMembers = await fetchMinterGroupMembers() const minterAdmins = await fetchMinterGroupAdmins() for (const card of cards) { try { const cardDataResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: card.name, service: "BLOG_POST", identifier: card.identifier, }) if (!cardDataResponse || !cardDataResponse.poll) { card._adminVotes = 0 card._adminYes = 0 card._minterVotes = 0 card._minterYes = 0 continue } const pollResults = await fetchPollResults(cardDataResponse.poll); const { adminYes, adminNo, minterYes, minterNo } = await processPollData( pollResults, minterGroupMembers, minterAdmins, cardDataResponse.creator, card.identifier ) card._adminVotes = adminYes + adminNo card._adminYes = adminYes card._minterVotes = minterYes + minterNo card._minterYes = minterYes } catch (error) { console.warn(`Error fetching or processing poll for card ${card.identifier}:`, error) card._adminVotes = 0 card._adminYes = 0 card._minterVotes = 0 card._minterYes = 0 } } if (ascending) { // least votes first cards.sort((a, b) => { const diffAdminTotal = a._adminVotes - b._adminVotes if (diffAdminTotal !== 0) return diffAdminTotal const diffAdminYes = a._adminYes - b._adminYes if (diffAdminYes !== 0) return diffAdminYes const diffMinterTotal = a._minterVotes - b._minterVotes if (diffMinterTotal !== 0) return diffMinterTotal return a._minterYes - b._minterYes }) } else { // most votes first cards.sort((a, b) => { const diffAdminTotal = b._adminVotes - a._adminVotes if (diffAdminTotal !== 0) return diffAdminTotal const diffAdminYes = b._adminYes - a._adminYes if (diffAdminYes !== 0) return diffAdminYes const diffMinterTotal = b._minterVotes - a._minterVotes if (diffMinterTotal !== 0) return diffMinterTotal return b._minterYes - a._minterYes }) } } const removeSkeleton = (cardIdentifier) => { const skeletonCard = document.getElementById(`skeleton-${cardIdentifier}`) if (skeletonCard) { skeletonCard.remove() } } const replaceSkeleton = (cardIdentifier, htmlContent) => { const skeletonCard = document.getElementById(`skeleton-${cardIdentifier}`) if (skeletonCard) { skeletonCard.outerHTML = htmlContent } } const createSkeletonCardHTML = (cardIdentifier) => { return ` <div id="skeleton-${cardIdentifier}" class="skeleton-card" style="padding: 10px; border: 1px solid gray; margin: 10px 0;"> <div style="display: flex; align-items: center;"> <div><p style="color:rgb(174, 174, 174)">LOADING CARD...</p></div> <div style="width: 50px; height: 50px; background-color: #ccc; border-radius: 50%;"></div> <div style="margin-left: 10px;"> <div style="width: 120px; height: 20px; background-color: #ccc; margin-bottom: 5px;"></div> <div style="width: 80px; height: 15px; background-color: #ddd;"></div> </div> </div> <div style="margin-top: 10px;"> <div style="width: 100%; height: 80px; background-color: #eee; color:rgb(17, 24, 28); padding: 0.22vh"><p>PLEASE BE PATIENT</p><p style="color: #11121c"> While data loads from QDN...</div> </div> </div> ` } // Function to check and fech an existing Minter Card if attempting to publish twice ---------------------------------------- const fetchExistingCard = async (cardIdentifierPrefix) => { try { const response = await searchSimple('BLOG_POST', `${cardIdentifierPrefix}`, `${userState.accountName}`, 0, 0, '', true) console.log(`SEARCH_QDN_RESOURCES response: ${JSON.stringify(response, null, 2)}`) if (!response || !Array.isArray(response) || response.length === 0) { console.log("No cards found for the current user.") return null } else if (response.length === 1) { // we don't need to go through all of the rest of the checks and filtering nonsense if there's only a single result, just return it. const mostRecentCard = response[0] isExistingCard = true const cardDataResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: userState.accountName, // User's account name service: "BLOG_POST", identifier: mostRecentCard.identifier }) existingCardIdentifier = mostRecentCard.identifier existingCardData = cardDataResponse isExistingCard = true return cardDataResponse } const validatedCards = await Promise.all( response.map(async card => { const isValid = await validateCardStructure(card) return isValid ? card : null }) ) const validCards = validatedCards.filter(card => card !== null) if (validCards.length > 0) { const mostRecentCard = validCards.sort((a, b) => b.created - a.created)[0] const cardDataResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: userState.accountName, // User's account name service: "BLOG_POST", identifier: mostRecentCard.identifier }) existingCardIdentifier = mostRecentCard.identifier existingCardData = cardDataResponse isExistingCard = true console.log("Full card data fetched successfully:", cardDataResponse) return cardDataResponse } 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 validateCardStructure = async (card) => { return ( typeof card === "object" && card.name && card.service === "BLOG_POST" && card.identifier && !card.identifier.includes("comment") && card.created ) } // Load existing card data passed, into the form for editing ------------------------------------- const loadCardIntoForm = async (cardData) => { console.log("Loading existing card data:", cardData) document.getElementById("card-header").value = cardData.header document.getElementById("card-content").value = cardData.content const linksContainer = document.getElementById("links-container") linksContainer.innerHTML = "" cardData.links.forEach(link => { const linkInput = document.createElement("input") linkInput.type = "text" linkInput.className = "card-link" linkInput.value = link; linksContainer.appendChild(linkInput) }) } // Main function to publish a new Minter Card ----------------------------------------------- const publishCard = async (cardIdentifierPrefix) => { const minterGroupData = await fetchMinterGroupMembers() const minterGroupAddresses = minterGroupData.map(m => m.member) const userAddress = userState.accountAddress if (minterGroupAddresses.includes(userAddress)) { alert("You are already a Minter and cannot publish a new card!") return } 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://")) if (!header || !content) { alert("Header and content are required!") return } const cardIdentifier = isExistingCard ? existingCardIdentifier : `${cardIdentifierPrefix}-${await uid()}` const pollName = `${cardIdentifier}-poll` const pollDescription = `Mintership Board Poll for ${userState.accountName}` const cardData = { header, content, links, creator: userState.accountName, creatorAddress: userState.accountAddress, 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)) } await qortalRequest({ action: "PUBLISH_QDN_RESOURCE", name: userState.accountName, service: "BLOG_POST", identifier: cardIdentifier, data64: base64CardData, }) if (!isExistingCard){ await qortalRequest({ action: "CREATE_POLL", pollName, pollDescription, pollOptions: ['Yes, No'], pollOwnerAddress: userState.accountAddress, }) alert("Card and poll published successfully!") } if (isExistingCard){ alert("Card Updated Successfully! (No poll updates possible)") isExistingCard = false } document.getElementById("publish-card-form").reset() document.getElementById("publish-card-view").style.display = "none" document.getElementById("cards-container").style.display = "flex" await loadCards(minterCardIdentifierPrefix) } catch (error) { console.error("Error publishing card or poll:", error) alert("Failed to publish card and poll.") } } let globalVoterMap = new Map() const processPollData= async (pollData, minterGroupMembers, minterAdmins, creator, cardIdentifier) => { if (!pollData || !Array.isArray(pollData.voteWeights) || !Array.isArray(pollData.votes)) { console.warn("Poll data is missing or invalid. pollData:", pollData) return { adminYes: 0, adminNo: 0, minterYes: 0, minterNo: 0, totalYes: 0, totalNo: 0, totalYesWeight: 0, totalNoWeight: 0, detailsHtml: `<p>Poll data is invalid or missing.</p>`, userVote: null } } const memberAddresses = minterGroupMembers.map(m => m.member) const minterAdminAddresses = minterAdmins.map(m => m.member) const adminGroupsMembers = await fetchAllAdminGroupsMembers() const featureTriggerPassed = await featureTriggerCheck() const groupAdminAddresses = adminGroupsMembers.map(m => m.member) let adminAddresses = [...minterAdminAddresses] if (!featureTriggerPassed) { console.log(`featureTrigger is NOT passed, only showing admin results from Minter Admins and Group Admins`) adminAddresses = [...minterAdminAddresses, ...groupAdminAddresses] } let adminYes = 0, adminNo = 0 let minterYes = 0, minterNo = 0 let yesWeight = 0, noWeight = 0 let userVote = null for (const w of pollData.voteWeights) { if (w.optionName.toLowerCase() === 'yes') { yesWeight = w.voteWeight } else if (w.optionName.toLowerCase() === 'no') { noWeight = w.voteWeight } } const voterPromises = pollData.votes.map(async (vote) => { const optionIndex = vote.optionIndex; // 0 => yes, 1 => no const voterPublicKey = vote.voterPublicKey const voterAddress = await getAddressFromPublicKey(voterPublicKey) if (voterAddress === userState.accountAddress) { userVote = optionIndex } if (optionIndex === 0) { if (adminAddresses.includes(voterAddress)) { adminYes++ } else if (memberAddresses.includes(voterAddress)) { minterYes++ } else { console.log(`voter ${voterAddress} is not a minter nor an admin... Not included in aggregates.`) } } else if (optionIndex === 1) { if (adminAddresses.includes(voterAddress)) { adminNo++ } else if (memberAddresses.includes(voterAddress)) { minterNo++ } else { console.log(`voter ${voterAddress} is not a minter nor an admin... Not included in aggregates.`) } } let voterName = '' try { const nameInfo = await getNameFromAddress(voterAddress) if (nameInfo) { voterName = nameInfo if (nameInfo === voterAddress) voterName = '' } } catch (err) { console.warn(`No name for address ${voterAddress}`, err) } let blocksMinted = 0 try { const addressInfo = await getAddressInfo(voterAddress) blocksMinted = addressInfo?.blocksMinted || 0 } catch (e) { console.warn(`Failed to get addressInfo for ${voterAddress}`, e) } const isAdmin = adminAddresses.includes(voterAddress) const isMinter = memberAddresses.includes(voterAddress) return { optionIndex, voterPublicKey, voterAddress, voterName, isAdmin, isMinter, blocksMinted } }) const allVoters = await Promise.all(voterPromises) const yesVoters = [] const noVoters = [] let totalMinterAndAdminYesWeight = 0 let totalMinterAndAdminNoWeight = 0 for (const v of allVoters) { if (v.optionIndex === 0) { yesVoters.push(v) totalMinterAndAdminYesWeight+=v.blocksMinted } else if (v.optionIndex === 1) { noVoters.push(v) totalMinterAndAdminNoWeight+=v.blocksMinted } } yesVoters.sort((a,b) => b.blocksMinted - a.blocksMinted) noVoters.sort((a,b) => b.blocksMinted - a.blocksMinted) const sortedAllVoters = allVoters.sort((a,b) => b.blocksMinted - a.blocksMinted) await createVoterMap(sortedAllVoters, cardIdentifier) const yesTableHtml = buildVotersTableHtml(yesVoters, /* tableColor= */ "green") const noTableHtml = buildVotersTableHtml(noVoters, /* tableColor= */ "red") const detailsHtml = ` <div class="poll-details-container" id'"${creator}-poll-details"> <h1 style ="color:rgb(123, 123, 85); text-align: center; font-size: 2.0rem">${creator}'s</h1><h3 style="color: white; text-align: center; font-size: 1.8rem"> Support Poll Result Details</h3> <h4 style="color: green; text-align: center;">Yes Vote Details</h4> ${yesTableHtml} <h4 style="color: red; text-align: center; margin-top: 2em;">No Vote Details</h4> ${noTableHtml} </div> ` const totalYes = adminYes + minterYes const totalNo = adminNo + minterNo return { adminYes, adminNo, minterYes, minterNo, totalYes, totalNo, totalYesWeight: totalMinterAndAdminYesWeight, totalNoWeight: totalMinterAndAdminNoWeight, detailsHtml, userVote } } const createVoterMap = async (voters, cardIdentifier) => { const voterMap = new Map() voters.forEach((voter) => { const voterNameOrAddress = voter.voterName || voter.voterAddress voterMap.set(voterNameOrAddress, { vote: voter.optionIndex === 0 ? "yes" : "no", // Use optionIndex directly voterType: voter.isAdmin ? "Admin" : voter.isMinter ? "Minter" : "User", blocksMinted: voter.blocksMinted, }) }) globalVoterMap.set(cardIdentifier, voterMap) } const buildVotersTableHtml = (voters, tableColor) => { if (!voters.length) { return `<p>No voters here.</p>` } // Decide extremely dark background for the <tbody> let bodyBackground if (tableColor === "green") { bodyBackground = "rgba(0, 18, 0, 0.8)" // near-black green } else if (tableColor === "red") { bodyBackground = "rgba(30, 0, 0, 0.8)" // near-black red } else { // fallback color if needed bodyBackground = "rgba(40, 20, 10, 0.8)" } // tableColor is used for the <thead>, bodyBackground for the <tbody> const minterColor = 'rgb(98, 122, 167)' const adminColor = 'rgb(44, 209, 151)' const userColor = 'rgb(102, 102, 102)' return ` <table style=" width: 100%; border-style: dotted; border-width: 0.15rem; border-color: #576b6f; margin-bottom: 1em; border-collapse: collapse; "> <thead style="background: ${tableColor}; color:rgb(238, 238, 238) ;"> <tr style="font-size: 1.5rem;"> <th style="padding: 0.1rem; text-align: center;">Voter Name/Address</th> <th style="padding: 0.1rem; text-align: center;">Voter Type</th> <th style="padding: 0.1rem; text-align: center;">Voter Weight(=BlocksMinted)</th> </tr> </thead> <!-- Tbody with extremely dark green or red --> <tbody style="background-color: ${bodyBackground}; color: #c6c6c6;"> ${voters .map(v => { const userType = v.isAdmin ? "Admin" : v.isMinter ? "Minter" : "User" const pollName = v.pollName const displayName = v.voterName ? v.voterName : v.voterAddress return ` <tr style="font-size: 1.2rem; border-width: 0.1rem; border-style: dotted; border-color: lightgrey; font-weight: bold;"> <td style="padding: 1.2rem; border-width: 0.1rem; border-style: dotted; border-color: lightgrey; text-align: center; color:${userType === 'Admin' ? adminColor : v.isMinter? minterColor : userColor };">${displayName}</td> <td style="padding: 1.2rem; border-width: 0.1rem; border-style: dotted; border-color: lightgrey; text-align: center; color:${userType === 'Admin' ? adminColor : v.isMinter? minterColor : userColor };">${userType}</td> <td style="padding: 1.2rem; border-width: 0.1rem; border-style: dotted; border-color: lightgrey; text-align: center; color:${userType === 'Admin' ? adminColor : v.isMinter? minterColor : userColor };">${v.blocksMinted}</td> </tr> ` }) .join("")} </tbody> </table> ` } // Post a comment on a card. --------------------------------- const postComment = async (cardIdentifier) => { const commentInput = document.getElementById(`new-comment-${cardIdentifier}`) const commentText = commentInput.value.trim() if (!commentText) { alert('Comment cannot be empty!') return } try { //Ensure the user is not on the blockList prior to allowing them to publish a comment. const blockedNames = await fetchBlockList() if (blockedNames.includes(userState.accountName)) { alert('You are on the block list and cannot publish comments.') return } const commentData = { content: commentText, creator: userState.accountName, timestamp: Date.now(), } const uniqueCommentIdentifier = `comment-${cardIdentifier}-${await uid()}` let base64CommentData = await objectToBase64(commentData) if (!base64CommentData) { console.log('objectToBase64 failed, fallback to btoa()') base64CommentData = btoa(JSON.stringify(commentData)) } await qortalRequest({ action: 'PUBLISH_QDN_RESOURCE', name: userState.accountName, service: 'BLOG_POST', identifier: uniqueCommentIdentifier, data64: base64CommentData, }) commentInput.value = '' } catch (error) { console.error('Error posting comment:', error) alert('Failed to post comment. Error: ' + error) } } //Fetch the comments for a card with passed card identifier ---------------------------- const fetchCommentsForCard = async (cardIdentifier) => { try { const response = await searchSimple('BLOG_POST',`comment-${cardIdentifier}`, '', 0, 0, '', 'false') return response } catch (error) { console.error(`Error fetching comments for ${cardIdentifier}:`, error) return [] } } const displayComments = async (cardIdentifier) => { try { const comments = await fetchCommentsForCard(cardIdentifier) const commentsContainer = document.getElementById(`comments-container-${cardIdentifier}`) commentsContainer.innerHTML = "" const blockedNames = await fetchBlockList() console.log("Loaded block list:", blockedNames) const voterMap = globalVoterMap.get(cardIdentifier) || new Map() const commentHTMLArray = await Promise.all( comments.map(async (comment) => { try { const commentDataResponse = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: comment.name, service: "BLOG_POST", identifier: comment.identifier }) if (!commentDataResponse || !commentDataResponse.creator) { return null } const commenterName = commentDataResponse.creator const voterInfo = voterMap.get(commenterName) let commentColor = "transparent" let adminBadge = "" if (blockedNames.includes(commenterName)) { console.warn(`Skipping blocked commenter: ${commenterName}`) return null } if (voterInfo) { if (voterInfo.voterType === "Admin") { commentColor = voterInfo.vote === "yes" ? "rgba(21, 150, 21, 0.6)" : "rgba(212, 37, 64, 0.6)" // Light green for yes, light red for no const badgeColor = voterInfo.vote === "yes" ? "rgb(206, 195, 77)" : "rgb(121, 119, 90)" adminBadge = `<span style="color: ${badgeColor}; font-weight: bold; margin-left: 0.5em;">(Admin)</span>` } else { commentColor = voterInfo.vote === "yes" ? "rgba(0, 100, 0, 0.3)" : "rgba(100, 0, 0, 0.3)" // Darker green for yes, darker red for no } } const timestamp = new Date(commentDataResponse.timestamp).toLocaleString() return ` <div class="comment" style="border: 1px solid gray; margin: 1vh 0; padding: 1vh; background: ${commentColor};"> <p> <strong>${commenterName}</strong> ${adminBadge} </p> <p>${commentDataResponse.content}</p> <p><i>${timestamp}</i></p> </div> ` } catch (err) { console.error(`Error with comment ${comment.identifier}:`, err) return null } }) ) commentHTMLArray .filter(html => html !== null) .forEach(commentHTML => { commentsContainer.insertAdjacentHTML('beforeend', commentHTML) }) } catch (err) { console.error(`Error displaying comments for ${cardIdentifier}:`, err) } } // Toggle comments from being shown or not, with passed cardIdentifier for comments being toggled -------------------- const toggleComments = async (cardIdentifier) => { const commentsSection = document.getElementById(`comments-section-${cardIdentifier}`) const commentButton = document.getElementById(`comment-button-${cardIdentifier}`) if (!commentsSection || !commentButton) return const count = commentButton.dataset.commentCount const isHidden = (commentsSection.style.display === 'none' || !commentsSection.style.display) if (isHidden) { // Show comments commentButton.textContent = "LOADING..." await displayComments(cardIdentifier) commentsSection.style.display = 'block' // Change the button text to 'HIDE COMMENTS' commentButton.textContent = 'HIDE COMMENTS' } else { // Hide comments commentsSection.style.display = 'none' commentButton.textContent = `COMMENTS (${count})` } } const countComments = async (cardIdentifier) => { try { const response = await searchSimple('BLOG_POST', `comment-${cardIdentifier}`, '', 0, 0, '', 'false') return Array.isArray(response) ? response.length : 0 } catch (error) { console.error(`Error fetching comment count for ${cardIdentifier}:`, error) return 0 } } const createModal = (modalType='') => { if (document.getElementById(`${modalType}-modal`)) { return } const isIframe = (modalType === 'links') const modalHTML = ` <div id="${modalType}-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.50); z-index: 10000;"> <div id="${modalType}-modalContainer" style="position: relative; margin: 10% auto; width: 80%; height: 70%; background:rgba(0, 0, 0, 0.80) ; border-radius: 10px; overflow: hidden;"> ${ isIframe ? `<iframe id="${modalType}-modalContent" src="" style="width: 100%; height: 100%; border: none;"> </iframe>` : `<div id="${modalType}-modalContent" style="width: 100%; height: 100%; overflow: auto;"> </div>` } <button onclick="closeModal('${modalType}')" style="position: absolute; top: 0.2rem; right: 0.2rem; background:rgba(0, 0, 0, 0.66); color: white; border: none; font-size: 2.2rem; padding: 0.4rem 1rem; border-radius: 0.33rem; border-style: dashed; border-color:rgb(213, 224, 225); " onmouseover="this.style.backgroundColor='rgb(73, 7, 7) '" onmouseout="this.style.backgroundColor='rgba(5, 14, 11, 0.63) '"> X </button> </div> </div> ` document.body.insertAdjacentHTML('beforeend', modalHTML) const modal = document.getElementById(`${modalType}-modal`) window.addEventListener('click', (event) => { if (event.target === modal) { closeModal(modalType) } }) } const openLinksModal = async (link) => { const processedLink = await processLink(link) const modal = document.getElementById('links-modal') const modalContent = document.getElementById('links-modalContent') modalContent.src = processedLink modal.style.display = 'block' } const closeModal = async (modalType='links') => { const modal = document.getElementById(`${modalType}-modal`) const modalContent = document.getElementById(`${modalType}-modalContent`) if (modal) { modal.style.display = 'none' } if (modalContent) { modalContent.src = '' } } const processLink = async (link) => { if (link.startsWith('qortal://')) { const match = link.match(/^qortal:\/\/([^/]+)(\/.*)?$/) if (match) { const firstParam = match[1].toUpperCase() const remainingPath = match[2] || "" const themeColor = window._qdnTheme || 'default' await new Promise(resolve => setTimeout(resolve, 10)) return `/render/${firstParam}${remainingPath}?theme=${themeColor}` } } return link } const togglePollDetails = (cardIdentifier) => { const detailsDiv = document.getElementById(`poll-details-${cardIdentifier}`) const modal = document.getElementById(`poll-details-modal`) const modalContent = document.getElementById(`poll-details-modalContent`) if (!detailsDiv || !modal || !modalContent) return modalContent.innerHTML = detailsDiv.innerHTML modal.style.display = 'block' window.onclick = (event) => { if (event.target === modal) { modal.style.display = 'none' } } } const generateDarkPastelBackgroundBy = (name) => { let hash = 0 for (let i = 0; i < name.length; i++) { hash = (hash << 5) - hash + name.charCodeAt(i) hash |= 0 } const safeHash = Math.abs(hash) const hueSteps = 69.69 const hueIndex = safeHash % hueSteps const hueRange = 288 const hue = 140 + (hueIndex * (hueRange / hueSteps)) const satSteps = 13.69 const satIndex = safeHash % satSteps const saturation = 18 + (satIndex * 1.333) const lightSteps = 3.69 const lightIndex = safeHash % lightSteps const lightness = 7 + lightIndex return `hsl(${hue}, ${saturation}%, ${lightness}%)` } const handleInviteMinter = async (minterName) => { try { const blockInfo = await getLatestBlockInfo() const blockHeight = blockInfo.height const minterAccountInfo = await getNameInfo(minterName) const minterAddress = await minterAccountInfo.owner let adminPublicKey let txGroupId if (blockHeight >= GROUP_APPROVAL_FEATURE_TRIGGER_HEIGHT){ if (userState.isMinterAdmin){ adminPublicKey = await getPublicKeyByName(userState.accountName) txGroupId = 694 }else{ console.warn(`user is not a minter admin, cannot create invite!`) return } }else { adminPublicKey = await getPublicKeyByName(userState.accountName) txGroupId = 0 } const fee = 0.01 const timeToLive = 864000 console.log(`about to attempt group invite, minterAddress: ${minterAddress}, adminPublicKey: ${adminPublicKey}`) const inviteTransaction = await createGroupInviteTransaction(minterAddress, adminPublicKey, 694, minterAddress, timeToLive, txGroupId, fee) const signedTransaction = await qortalRequest({ action: "SIGN_TRANSACTION", unsignedBytes: inviteTransaction }) console.warn(`signed transaction`,signedTransaction) const processResponse = await processTransaction(signedTransaction) if (typeof processResponse === 'object') { // The successful object might have a "signature" or "type" or "approvalStatus" console.log("Invite transaction success object:", processResponse) alert(`${minterName} has been successfully invited! Wait for confirmation...Transaction Response: ${JSON.stringify(processResponse)}`) } else { // fallback string or something console.log("Invite transaction raw text response:", processResponse) alert(`Invite transaction response: ${JSON.stringify(processResponse)}`) } } catch (error) { console.error("Error inviting minter:", error) alert("Error inviting minter. Please try again.") } } const escapeHTML = (str) => { return str .replace(/'/g, ''') .replace(/"/g, '"') } const createInviteButtonHtml = (creator, cardIdentifier) => { const escapedCreator = escapeHTML(creator) return ` <div id="invite-button-container-${cardIdentifier}" style="margin-top: 1em;"> <button onclick="handleInviteMinter('${escapedCreator}')" style="padding: 10px; background:rgb(0, 109, 76) ; color: white; border: dotted; border-color: white; cursor: pointer; border-radius: 5px;" onmouseover="this.style.backgroundColor='rgb(25, 47, 39) '" onmouseout="this.style.backgroundColor='rgba(7, 122, 101, 0.63) '" > Create Minter Invite </button> </div> ` } const featureTriggerCheck = async () => { const latestBlockInfo = await getLatestBlockInfo() const isBlockPassed = latestBlockInfo.height >= GROUP_APPROVAL_FEATURE_TRIGGER_HEIGHT if (isBlockPassed) { console.warn(`featureTrigger check (verifyFeatureTrigger) determined block has PASSED:`, isBlockPassed) featureTriggerPassed = true return true } else { console.warn(`featureTrigger check (verifyFeatureTrigger) determined block has NOT PASSED:`, isBlockPassed) featureTriggerPassed = false return false } } const checkAndDisplayInviteButton = async (adminYes, creator, cardIdentifier) => { const isSomeTypaAdmin = userState.isAdmin || userState.isMinterAdmin const isBlockPassed = await featureTriggerCheck() const minterAdmins = await fetchMinterGroupAdmins() // default needed admin count = 9, or 40% if block has passed let minAdminCount = 9 if (isBlockPassed) { minAdminCount = Math.ceil(minterAdmins.length * 0.4) console.warn(`Using 40% => ${minAdminCount}`) } // if not enough adminYes votes, no invite button if (adminYes < minAdminCount) { console.warn(`Admin votes not high enough (have=${adminYes}, need=${minAdminCount}). No button.`) return null } console.log(`passed initial button creation checks (adminYes >= ${minAdminCount})`) // get user's address from 'creator' name const minterNameInfo = await getNameInfo(creator) if (!minterNameInfo || !minterNameInfo.owner) { console.warn(`No valid nameInfo for ${creator}, skipping invite button.`) return null } const minterAddress = minterNameInfo.owner // fetch all final KICK/BAN tx const { finalKickTxs, finalBanTxs } = await fetchAllKickBanTxData() const { finalInviteTxs, pendingInviteTxs } = await fetchAllInviteTransactions() // check if there's a final (non-pending) KICK or BAN for this user const priorKick = finalKickTxs.some(tx => tx.member === minterAddress) const priorBan = finalBanTxs.some(tx => tx.offender === minterAddress) const existingInvite = finalInviteTxs.some(tx => tx.invitee === minterAddress) const pendingInvite = pendingInviteTxs.some(tx => tx.invitee === minterAddress) const priorBanOrKick = (priorBan || priorKick) console.warn(`PriorBanOrKick determination for ${minterAddress}:`, priorBanOrKick) // build the normal invite button & groupApprovalHtml let inviteButtonHtml = "" if (existingInvite || pendingInvite){ console.warn(`There is an EXISTING INVITE for this user! No invite button being created... existing: (${existingInvite}, pending: ${pendingInvite})`) inviteButtonHtml = '' } inviteButtonHtml = isSomeTypaAdmin ? createInviteButtonHtml(creator, cardIdentifier) : "" const groupApprovalHtml = await checkGroupApprovalAndCreateButton(minterAddress, cardIdentifier, "GROUP_INVITE") // if user had no prior KICK/BAN if (!priorBanOrKick) { console.log(`No prior kick/ban found, creating invite (or approve) button...`) console.warn(`Existing Numbers - adminYes/minAdminCount: ${adminYes}/${minAdminCount}`) // if there's already a pending GROUP_INVITE, return that approval button if (groupApprovalHtml) { console.warn(`groupApprovalCheck found existing groupApproval, returning approval button instead of invite button...`) return groupApprovalHtml } console.warn(`No pending approvals or prior kick/ban found, returning invite button...`) return inviteButtonHtml } else { // priorBanOrKick is true => show both console.warn(`Prior kick/ban found! Including BOTH buttons...`) return inviteButtonHtml + groupApprovalHtml } } const findPendingApprovalTxForAddress = async (address, txType, limit = 0, offset = 0) => { // 1) Fetch all pending transactions const pendingTxs = await searchPendingTransactions(limit, offset, false) // if a txType is passed, return the results related to that type, if not, then return any pending tx of the potential types. let relevantTypes if (txType) { relevantTypes = new Set([txType]) } else { relevantTypes = new Set(["GROUP_INVITE", "GROUP_BAN", "GROUP_KICK", "ADD_GROUP_ADMIN", "REMOVE_GROUP_ADMIN"]) } // Filter pending TX for relevant types const relevantTxs = pendingTxs.filter((tx) => relevantTypes.has(tx.type)) const matchedTxs = relevantTxs.filter((tx) => { switch (tx.type) { case "GROUP_INVITE": return tx.invitee === address case "GROUP_BAN": return tx.offender === address case "GROUP_KICK": return tx.member === address case "ADD_GROUP_ADMIN": return tx.member === address case "REMOVE_GROUP_ADMIN": return tx.admin === address default: return false } }) console.warn(`matchedTxs:`,matchedTxs) //Sort oldest→newest by timestamp, so matchedTxs[0] is the oldest matchedTxs.sort((a, b) => a.timestamp - b.timestamp) return matchedTxs // Array of matching pending transactions } const checkGroupApprovalAndCreateButton = async (address, cardIdentifier, transactionType) => { // We are going to be verifying that the address isn't already a minter, before showing GROUP_APPROVAL buttons potentially... if (transactionType === "GROUP_INVITE") { console.log(`This is a GROUP_INVITE check for group approval... Checking that user isn't already a minter...`) const minterMembers = await fetchMinterGroupMembers() const minterGroupAddresses = minterMembers.map(m => m.member) if (minterGroupAddresses.includes(address)) { console.warn(`User is already a minter, will not be creating group_approval buttons`) return null } } const approvalSearchResults = await searchTransactions({ txTypes: ['GROUP_APPROVAL'], confirmationStatus: 'CONFIRMED', limit: 0, reverse: false, offset: 0, startBlock: 1990000, blockLimit: 0, txGroupId: 0 }) const pendingApprovals = await findPendingApprovalTxForAddress(address, transactionType, 0, 0) let isSomeTypaAdmin = userState.isAdmin || userState.isMinterAdmin // If no pending transaction found, return null if (!pendingApprovals || pendingApprovals.length === 0) { console.warn("no pending approval transactions found, returning null...") return null } const txSig = pendingApprovals[0].signature // Find the relevant signature. (First approval) const relevantApprovals = approvalSearchResults.filter( (approvalTx) => approvalTx.pendingSignature === txSig ) const { tableHtml, uniqueApprovalCount } = await buildApprovalTableHtml( relevantApprovals, getNameFromAddress ) if (transactionType === "GROUP_INVITE" && isSomeTypaAdmin) { const approvalButtonHtml = ` <div style="display: flex; flex-direction: column; margin-top: 1em;"> <p style="color: rgb(181, 214, 100);"> Existing ${transactionType} Approvals: ${uniqueApprovalCount} </p> ${tableHtml} <div id="approval-button-container-${cardIdentifier}" style="margin-top: 1em;"> <button style=" padding: 8px; background: rgb(37, 97, 99); color: rgb(215, 215, 215); border: 1px solid #333; border-color: white; border-radius: 5px; cursor: pointer; " onmouseover="this.style.backgroundColor='rgb(25, 47, 39)'" onmouseout="this.style.backgroundColor='rgb(37, 96, 99)'" onclick="handleGroupApproval('${txSig}')" > Approve Invite Tx </button> </div> </div> ` return approvalButtonHtml } if (transactionType === "GROUP_KICK" && isSomeTypaAdmin) { const approvalButtonHtml = ` <div style="display: flex; flex-direction: column; margin-top: 1em;"> <p style="color: rgb(199, 100, 64);"> Existing ${transactionType} Approvals: ${uniqueApprovalCount} </p> ${tableHtml} <div id="approval-button-container-${cardIdentifier}" style="margin-top: 1em;"> <button style=" padding: 8px; background: rgb(119, 91, 21); color: rgb(201, 255, 251); border: 1px solid #333; border-color: rgb(102, 69, 60); border-radius: 5px; cursor: pointer; " onmouseover="this.style.backgroundColor='rgb(50, 52, 51)'" onmouseout="this.style.backgroundColor='rgb(119, 91, 21)'" onclick="handleGroupApproval('${txSig}')" > Approve Kick Tx </button> </div> </div> ` return approvalButtonHtml } if (transactionType === "GROUP_BAN" && isSomeTypaAdmin) { const approvalButtonHtml = ` <div style="display: flex; flex-direction: column; margin-top: 1em;"> <p style="color: rgb(189, 40, 40);"> Existing ${transactionType} Approvals: ${uniqueApprovalCount} </p> ${tableHtml} <div id="approval-button-container-${cardIdentifier}" style="margin-top: 1em;"> <button style=" padding: 8px; background: rgb(54, 7, 7); color: rgb(201, 255, 251); border: 1px solid #333; border-color: rgb(204, 94, 94); border-radius: 5px; cursor: pointer; " onmouseover="this.style.backgroundColor='rgb(50, 52, 51)'" onmouseout="this.style.backgroundColor='rgb(54, 7, 7)'" onclick="handleGroupApproval('${txSig}')" > Approve Ban Tx </button> </div> </div> ` return approvalButtonHtml } if (transactionType === "ADD_GROUP_ADMIN" && isSomeTypaAdmin) { const approvalButtonHtml = ` <div style="display: flex; flex-direction: column; margin-top: 1em;"> <p style="color: rgb(40, 144, 189);"> Existing ${transactionType} Approvals: ${uniqueApprovalCount} </p> ${tableHtml} <div id="approval-button-container-${cardIdentifier}" style="margin-top: 1em;"> <button style=" padding: 8px; background: rgb(8, 71, 69); color: rgb(201, 255, 251); border: 1px solid #333; border-color: rgb(198, 252, 249); border-radius: 5px; cursor: pointer; " onmouseover="this.style.backgroundColor='rgb(17, 41, 29)'" onmouseout="this.style.backgroundColor='rgb(8, 71, 69)'" onclick="handleGroupApproval('${txSig}')" > Approve Add-Admin Tx </button> </div> </div> ` return approvalButtonHtml } if (transactionType === "REMOVE_GROUP_ADMIN" && isSomeTypaAdmin) { const approvalButtonHtml = ` <div style="display: flex; flex-direction: column; margin-top: 1em;"> <p style="color: rgb(189, 40, 40);"> Existing ${transactionType} Approvals: ${uniqueApprovalCount} </p> ${tableHtml} <div id="approval-button-container-${cardIdentifier}" style="margin-top: 1em;"> <button style=" padding: 8px; background: rgb(54, 7, 7); color: rgb(201, 255, 251); border: 1px solid #333; border-color: rgb(204, 94, 94); border-radius: 5px; cursor: pointer; " onmouseover="this.style.backgroundColor='rgb(50, 52, 51)'" onmouseout="this.style.backgroundColor='rgb(54, 7, 7)'" onclick="handleGroupApproval('${txSig}')" > Approve Remove-Admin Tx </button> </div> </div> ` return approvalButtonHtml } } const buildApprovalTableHtml = async (approvalTxs, getNameFunc) => { // Build a Map of adminAddress => one transaction (to handle multiple approvals from same admin) const approvalMap = new Map() for (const tx of approvalTxs) { const adminAddr = tx.creatorAddress if (!approvalMap.has(adminAddr)) { approvalMap.set(adminAddr, tx) } } // Turn the map into an array for iteration const approvalArray = Array.from(approvalMap, ([adminAddr, tx]) => ({ adminAddr, tx })) // Build table rows asynchronously, since we need getNameFromAddress const tableRows = await Promise.all( approvalArray.map(async ({ adminAddr, tx }) => { let adminName try { adminName = await getNameFunc(adminAddr) } catch (err) { console.warn(`Error fetching name for ${adminAddr}:`, err) adminName = null } const displayName = adminName && adminName !== adminAddr ? adminName : "(No registered name)" const dateStr = new Date(tx.timestamp).toLocaleString() return ` <tr> <td style="border: 1px solid rgb(255, 255, 255); padding: 4px; color: #234565">${displayName}</td> <td style="border: 1px solid rgb(255, 254, 254); padding: 4px;">${dateStr}</td> </tr> ` }) ) // The total unique approvals = number of entries in approvalMap const uniqueApprovalCount = approvalMap.size; // Wrap the table in a container with horizontal scroll: // 1) max-width: 100% makes it fit the parent (card) width // 2) overflow-x: auto allows scrolling if the table is too wide const containerHtml = ` <div style="max-width: 100%; overflow-x: auto;"> <table style="border: 1px solid #ccc; border-collapse: collapse; width: 100%;"> <thead> <tr style="background:rgba(6, 50, 59, 0.61);"> <th style="border: 1px solid #ffffff; padding: 4px;">Admin Name</th> <th style="border: 1px solid #ffffff; padding: 4px;">Approval Time</th> </tr> </thead> <tbody> ${tableRows.join("")} </tbody> </table> </div> ` // Return both the container-wrapped table and the count of unique approvals return { tableHtml: containerHtml, uniqueApprovalCount } } const handleGroupApproval = async (pendingSignature) => { try{ if (!userState.isMinterAdmin) { console.warn(`non-admin attempting to sign approval!`) return } const fee = 0.01 const adminPublicKey = await getPublicKeyByName(userState.accountName) const txGroupId = 0 const rawGroupApprovalTransaction = await createGroupApprovalTransaction(adminPublicKey, pendingSignature, txGroupId, fee) const signedGroupApprovalTransaction = await qortalRequest({ action: "SIGN_TRANSACTION", unsignedBytes: rawGroupApprovalTransaction }) let txToProcess = signedGroupApprovalTransaction const processGroupApprovalTx = await processTransaction(txToProcess) if (processGroupApprovalTx) { alert(`transaction processed, please wait for CONFIRMATION: ${JSON.stringify(processGroupApprovalTx)}`) } else { alert(`creating tx failed for some reason`) } }catch(error){ console.error(error) throw error } } const handleJoinGroup = async (minterAddress) => { try{ if (userState.accountAddress === minterAddress) { console.log(`minter user found `) const qRequestAttempt = await qortalRequest({ action: "JOIN_GROUP", groupId: 694 }) if (qRequestAttempt) { return true } const joinerPublicKey = getPublicKeyFromAddress(minterAddress) const fee = 0.01 const joinGroupTransactionData = await createGroupJoinTransaction(minterAddress, joinerPublicKey, 694, 0, fee) const signedJoinGroupTransaction = await qortalRequest({ action: "SIGN_TRANSACTION", unsignedBytes: joinGroupTransactionData }) let txToProcess = signedJoinGroupTransaction const processJoinGroupTransaction = await processTransaction(txToProcess) if (processJoinGroupTransaction){ console.warn(`processed JOIN_GROUP tx`,processJoinGroupTransaction) alert(`JOIN GROUP Transaction Processed Successfully, please WAIT FOR CONFIRMATION txData: ${JSON.stringify(processJoinGroupTransaction)}`) } } else { console.warn(`user is not the minter`) return '' } } catch(error){ throw error } } const getMinterAvatar = async (minterName) => { const avatarUrl = `/arbitrary/THUMBNAIL/${minterName}/qortal_avatar` try { const response = await fetch(avatarUrl, { method: 'HEAD' }) if (response.ok) { return `<img src="${avatarUrl}" alt="User Avatar" class="user-avatar" style="width: 50px; height: 50px; border-radius: 50%; align-self: center;">` } else { return '' } } catch (error) { console.error('Error checking avatar availability:', error) return '' } } const getNewestCommentTimestamp = async (cardIdentifier) => { try { // fetchCommentsForCard returns resources each with at least 'created' or 'updated' const comments = await fetchCommentsForCard(cardIdentifier) if (!comments || comments.length === 0) { // No comments => fallback to 0 (or card's own date, if you like) return 0 } // The newest can be determined by comparing 'updated' or 'created' const newestTimestamp = comments.reduce((acc, c) => { const cTime = c.updated || c.created || 0 return (cTime > acc) ? cTime : acc }, 0) return newestTimestamp } catch (err) { console.error('Failed to get newest comment timestamp:', err) return 0 } } // Create the overall Minter Card HTML ----------------------------------------------- const createCardHTML = async (cardData, pollResults, cardIdentifier, commentCount, cardUpdatedTime, bgColor, address, isExistingMinter=false) => { const { header, content, links, creator, creatorAddress, timestamp, poll } = cardData const formattedDate = cardUpdatedTime ? new Date(cardUpdatedTime).toLocaleString() : new Date(timestamp).toLocaleString() const avatarHtml = await getMinterAvatar(creator) const linksHTML = links.map((link, index) => ` <button onclick="openLinksModal('${link}')"> ${`Link ${index + 1} - ${link}`} </button> `).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, detailsHtml, userVote } = await processPollData(pollResults, minterGroupMembers, minterAdmins, creator, cardIdentifier) createModal('links') createModal('poll-details') const inviteButtonHtml = isExistingMinter ? "" : await checkAndDisplayInviteButton(adminYes, creator, cardIdentifier) let inviteHtmlAdd = (inviteButtonHtml) ? inviteButtonHtml : '' let finalBgColor = bgColor let invitedText = "" // for "INVITED" label if found const addressInfo = await getAddressInfo(address) const penaltyText = addressInfo.blocksMintedPenalty == 0 ? '' : '<p>(has Blocks Penalty)<p>' const adjustmentText = addressInfo.blocksMintedAdjustment == 0 ? '' : '<p>(has Blocks Adjustment)<p>' try { const invites = await fetchGroupInvitesByAddress(address) const hasMinterInvite = invites.some((invite) => invite.groupId === 694) if (userVote === 0) { finalBgColor = "rgba(1, 65, 39, 0.41)"; // or any green you want } else if (userVote === 1) { finalBgColor = "rgba(107, 3, 3, 0.3)"; // or any red you want } else if (isExistingMinter){ finalBgColor = "rgb(99, 99, 99)" invitedText = `<h4 style="color:rgb(135, 55, 16); margin-bottom: 0.5em;">EXISTING MINTER</h4>` } else if (hasMinterInvite) { // If so, override background color & add an "INVITED" label finalBgColor = "black"; invitedText = `<h4 style="color: gold; margin-bottom: 0.5em;">INVITED</h4>` if (userState.accountName === creator){ //Check also if the creator is the user, and display the join group button if so. inviteHtmlAdd = ` <div id="join-button-container-${cardIdentifier}" style="margin-top: 1em;"> <button style="padding: 8px; background: rgb(37, 99, 44); color:rgb(240, 240, 240); border: 1px solid rgb(255, 255, 255); border-radius: 5px; cursor: pointer;" onmouseover="this.style.backgroundColor='rgb(25, 47, 39) '" onmouseout="this.style.backgroundColor='rgb(37, 99, 44) '" onclick="handleJoinGroup('${userState.accountAddress}')"> Join MINTER Group </button> </div> ` }else{ console.log(`user is not the minter... NOT displaying any join button`) inviteHtmlAdd = '' } } //do not display invite button as they're already invited. Create a join button instead. } catch (error) { console.error("Error checking invites for user:", error) } return ` <div class="minter-card" style="background-color: ${finalBgColor}"> <div class="minter-card-header"> ${avatarHtml} <h3>${creator} - Level ${addressInfo.level}</h3> <p>${header}</p> ${penaltyText}${adjustmentText}${invitedText} </div> <div class="support-header"><h5>USER'S POST</h5></div> <div class="info"> ${content} </div> <div class="support-header"><h5>USER'S LINKS</h5></div> <div class="info-links"> ${linksHTML} </div> <div class="results-header support-header"><h5>CURRENT SUPPORT RESULTS</h5></div> <div class="minter-card-results"> <button onclick="togglePollDetails('${cardIdentifier}')">Display Poll Details</button> <div id="poll-details-${cardIdentifier}" style="display: none;"> ${detailsHtml} </div> ${inviteHtmlAdd} <div class="admin-results"> <span class="admin-yes">Admin Yes: ${adminYes}</span> <span class="admin-no">Admin No: ${adminNo}</span> </div> <div class="minter-results"> <span class="minter-yes">Minter Yes: ${minterYes}</span> <span class="minter-no">Minter No: ${minterNo}</span> </div> <div class="total-results"> <span class="total-yes">Total Yes: ${totalYes}</span> <span class="total-yes">Weight: ${totalYesWeight}</span> <span class="total-no">Total No: ${totalNo}</span> <span class="total-no">Weight: ${totalNoWeight}</span> </div> </div> <div class="support-header"><h5>SUPPORT ACTION FOR </h5><h5 style="color: #ffae42;">${creator}</h5> <p style="color: #c7c7c7; font-size: .65rem; margin-top: 1vh">(click COMMENTS button to open/close card comments)</p> </div> <div class="actions"> <div class="actions-buttons"> <button class="yes" onclick="voteYesOnPoll('${poll}')">YES</button> <button class="comment" id="comment-button-${cardIdentifier}" data-comment-count="${commentCount}" onclick="toggleComments('${cardIdentifier}')">COMMENTS (${commentCount})</button> <button class="no" onclick="voteNoOnPoll('${poll}')">NO</button> </div> </div> <div id="comments-section-${cardIdentifier}" class="comments-section" style="display: none; margin-top: 20px;"> <div id="comments-container-${cardIdentifier}" class="comments-container"></div> <textarea id="new-comment-${cardIdentifier}" placeholder="Write a comment..." style="width: 100%; margin-top: 10px;"></textarea> <button onclick="postComment('${cardIdentifier}')">Post Comment</button> </div> <p style="font-size: 0.75rem; margin-top: 3vh; color: #4496a1">By: ${creator} - ${formattedDate}</p> </div> ` }