Compare commits
15 Commits
testing-20
...
main
Author | SHA1 | Date | |
---|---|---|---|
731c53b5d4 | |||
20f9845610 | |||
5b49f0d4fc | |||
b2dde1ea56 | |||
5630f80a54 | |||
59bd5cc760 | |||
6f459d7e0a | |||
fe230a91d3 | |||
5443d159b0 | |||
e0c5a09378 | |||
509e3bf357 | |||
07f4fa3e6e | |||
7afa06623f | |||
51921992e2 | |||
f5ce634ff5 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
/.vscode
|
||||
/.sync*
|
Binary file not shown.
Binary file not shown.
49
README.md
Normal file
49
README.md
Normal file
@ -0,0 +1,49 @@
|
||||
### Q-Mintership-Alpha
|
||||
|
||||
Q-Mintership-Alpha is the currently utilized version of the Q-Mintership app published on qortal://APP/Q-Mintership.
|
||||
|
||||
As of Feb 27 2025 Q-Mintership-Alpha is still the published and utilized version of the app.
|
||||
|
||||
#### Q-Mintership's 'MinterBoard'
|
||||
|
||||
The MinterBoard of Q-Mintership, is the primary location for users to publish 'cards' with information about themselves, links to things they have published on QDN, etc... and obtain minting rights from the Minter Admins.
|
||||
|
||||
- Cards are created by any non-minter (either previous minter no longer in the group, or new accounts without minting rights).
|
||||
- Existing community members, existing minters, and Minter Admins, can vote/comment on the cards.
|
||||
- Once a card has obtained the minimum required number of admin votes (40% of the Minter Admin count), the card will then display additional features to those that have the rights to see them. (Minter Admins, and 'Forum Admins', however 'Forum Admins' cannot actually make use of the functionality, they are only able to view it for development purposes.)
|
||||
- The Minter Admins then initiate a PENDING GROUP_INVITE transaction.
|
||||
- The Minter Admins are then able to issue GROUP_APPROVAL transactions to approve the invite.
|
||||
- Once the required number of GROUP_APPROVAL transactions have been created, the GROUP_INVITE is no longer pending, and is active.
|
||||
- The would-be minter that published a card, can then see a new 'JOIN_GROUP' button on their card upon returning to the MinterBoard.
|
||||
- The user will then JOIN_GROUP to the MINTER group, ID 694. Thus allowing the ability to mint.
|
||||
- Upon joining the MINTER group, the user will then have the ability to create a MINTING KEY (the same way as it was created prior to the Mintership concept, however now it no longer requires users to be level 1, only requirement now is to be part of the MINTER group)
|
||||
- User then assigns their key to their node, and starts minting.
|
||||
|
||||
#### Q-Mintership 'AdminBoard'
|
||||
|
||||
- The AdminBoard is a separate board, encrypted to admins only, meant to be utilized for private decision-making between the admins.
|
||||
- The AdminBoard was also adapted to allow REMOVAL of MINTER group members, via GROUP_APPROVAL from the Minter Admins.
|
||||
- The REMOVAL functionality, at the moment, is private. Meaning only the admins that have access to the AdminBoard, can see the data. This will be changed in the future, and a new location where the data will be able to be seen publicly, will be created.
|
||||
|
||||
|
||||
#### Q-Mintership Forum
|
||||
|
||||
- The Forum portion of Q-Mintership is a public (and private) forum, allowing communications to take place in the fashion of long-term forum messages, replies, etc.
|
||||
- Publishing of images with previews, and various 'attachments' with data is also possible on the forum.
|
||||
- The forum has two public rooms by default, and one private room. General and Minter rooms are public, and Admin room is private.
|
||||
- The forum will be getting extensive updates in the future, and the Minter room will be made a publicly VIEWABLE room, but only able to be published to by MINTERS.
|
||||
|
||||
|
||||
#### Q-Mintership MAM Board
|
||||
|
||||
- The MAM Board (or ARBoard in the code) is built to allow the adding and removal of Minter Admins from the MINTER group. Proposals for additions or removals of certain accounts from and to the Minter takes place here.
|
||||
- This board also displays a list of the current Minter Admins, and has the ability to propose a removal of that user with a propose removal button.
|
||||
|
||||
|
||||
#### Additional
|
||||
|
||||
Many additional features and functions are planned for Q-Mintership, and increased performance and more will be added as time goes on.
|
||||
|
||||
Longer-term the plan is to re-write the app into React+TypeScript, which will make it MUCH faster and able to accommodate much more, with a component-based development style similar to that of the other React-based applications on Qortal (Q-Tube, Q-Blog, Q-Mail, etc.)
|
||||
|
||||
A fully featured data viewer and explorer function will be built into Q-Mintership in the future, along with a comprehensive notification system, and more.
|
@ -584,7 +584,7 @@ body {
|
||||
border-radius: 5px;
|
||||
padding: 20px;
|
||||
margin: 20px auto; /* center horizontally */
|
||||
max-width: 600px; /* limit width */
|
||||
/* max-width: 600px; */
|
||||
color: #ddd; /* text color */
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
@ -596,7 +596,7 @@ body {
|
||||
background-color:#000000;
|
||||
width: 90%;
|
||||
font-size: 1.8rem;
|
||||
color: #4d0000;
|
||||
color: #fff3f3;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
/* you could style the list items or bullet if you like */
|
||||
@ -616,7 +616,17 @@ body {
|
||||
background-color: #14161a;
|
||||
border: 1px solid #8caeb0;
|
||||
border-radius: 4px;
|
||||
color: #5c0101;
|
||||
color: #f19c9c;
|
||||
}
|
||||
|
||||
.invite-form input.invite-input {
|
||||
padding: 1rem;
|
||||
font-size: 2rem;
|
||||
line-height: 2;
|
||||
background-color: #14161a;
|
||||
border: 1px solid #8caeb0;
|
||||
border-radius: 4px;
|
||||
color: #dddddd;
|
||||
}
|
||||
|
||||
.publish-card-button {
|
||||
@ -707,30 +717,42 @@ body {
|
||||
background-color: #281e1e;
|
||||
}
|
||||
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.publish-card-view {
|
||||
width: 90%;
|
||||
padding: 2vh;
|
||||
}
|
||||
|
||||
.publish-card-button {
|
||||
font-size: 1.8vh;
|
||||
padding: 1.5vh;
|
||||
}
|
||||
|
||||
.publish-card-form button {
|
||||
font-size: 1.8vh;
|
||||
padding: 1.2vh;
|
||||
}
|
||||
.approve-invite-list-button {
|
||||
background-color: rgba(32, 88, 34, 0.554);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 1vw;
|
||||
padding: 1vh,2vh;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-cards-button {
|
||||
border-color: white;
|
||||
border-radius: 1.5vh;
|
||||
background-color: black;
|
||||
color: white;
|
||||
.approve-invite-list-button:hover {
|
||||
background-color: rgba(34, 186, 47, 0.84); /* a darker variant */
|
||||
}
|
||||
|
||||
.invite-approvals strong {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.invite-item {
|
||||
margin-bottom: 0.5em;
|
||||
background-color: rgba(31, 31, 31, 0.595);
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Top row: use flex for horizontal arrangement */
|
||||
.invite-top-row {
|
||||
display: flex;
|
||||
background-color:#173c52ae;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@ -754,8 +776,14 @@ body {
|
||||
.refresh-cards-button {
|
||||
border-color: white;
|
||||
border-radius: 1.5vh;
|
||||
background-color: black;
|
||||
background-color: rgba(0, 0, 0, 0.089);
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.refresh-cards-button:hover {
|
||||
background-color: rgba(35, 129, 136, 0.137);
|
||||
color: rgba(90, 201, 221, 0.793);
|
||||
}
|
||||
/* Two cards per row on medium screens */
|
||||
|
||||
|
@ -60,11 +60,13 @@ const loadAddRemoveAdminPage = async () => {
|
||||
<h3 style="color: #ddd;">Existing Promotion/Demotion Proposals</h3>
|
||||
<button id="refresh-cards-button" class="refresh-cards-button" style="padding: 10px;">Refresh Proposal Cards</button>
|
||||
<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>
|
||||
<option value="0">All Creation Dates</option>
|
||||
<option value="1">Last 1 Day</option>
|
||||
<option value="7">Last 7 Days</option>
|
||||
<option value="30">...Within 30 Days</option>
|
||||
<option value="45" selected>Published Within Last 45 Days</option>
|
||||
<option value="60">...Within 60 Days</option>
|
||||
<option value="90">...Within 90 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="cards-container" class="cards-container" style="margin-top: 1rem"">
|
||||
@ -95,6 +97,7 @@ const loadAddRemoveAdminPage = async () => {
|
||||
document.getElementById("refresh-cards-button").addEventListener("click", async () => {
|
||||
const cardsContainer = document.getElementById("cards-container")
|
||||
cardsContainer.innerHTML = "<p>Refreshing cards...</p>"
|
||||
await initializeCachedGroups()
|
||||
await loadCards(addRemoveIdentifierPrefix)
|
||||
})
|
||||
|
||||
@ -117,6 +120,13 @@ const loadAddRemoveAdminPage = async () => {
|
||||
linksContainer.appendChild(newLinkInput)
|
||||
})
|
||||
|
||||
const timeRangeSelectCheckbox = document.getElementById('time-range-select')
|
||||
if (timeRangeSelectCheckbox) {
|
||||
timeRangeSelectCheckbox.addEventListener('change', async (event) => {
|
||||
await loadCards(addRemoveIdentifierPrefix)
|
||||
})
|
||||
}
|
||||
|
||||
document.getElementById("publish-card-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault()
|
||||
await publishARCard(addRemoveIdentifierPrefix)
|
||||
@ -159,51 +169,63 @@ const fetchAllARTxData = async () => {
|
||||
txGroupId: 694,
|
||||
})
|
||||
|
||||
const { finalAddTxs, pendingAddTxs } = partitionAddTransactions(allAddTxs)
|
||||
const { finalRemTxs, pendingRemTxs } = partitionRemoveTransactions(allRemTxs)
|
||||
const { finalAddTxs, pendingAddTxs, expiredAddTxs } = partitionAddTransactions(allAddTxs)
|
||||
const { finalRemTxs, pendingRemTxs, expiredRemTxs } = partitionRemoveTransactions(allRemTxs)
|
||||
|
||||
// We are going to keep all transactions in order to filter more accurately for display purposes.
|
||||
console.log('Final addAdminTxs:', finalAddTxs);
|
||||
console.log('Pending addAdminTxs:', pendingAddTxs);
|
||||
console.log('Final remAdminTxs:', finalRemTxs);
|
||||
console.log('Pending remAdminTxs:', pendingRemTxs);
|
||||
console.log('Final addAdminTxs:', finalAddTxs)
|
||||
console.log('Pending addAdminTxs:', pendingAddTxs)
|
||||
console.log('expired addAdminTxs', expiredAddTxs)
|
||||
console.log('Final remAdminTxs:', finalRemTxs)
|
||||
console.log('Pending remAdminTxs:', pendingRemTxs)
|
||||
console.log('expired remAdminTxs', expiredRemTxs)
|
||||
|
||||
return {
|
||||
finalAddTxs,
|
||||
pendingAddTxs,
|
||||
expiredAddTxs,
|
||||
finalRemTxs,
|
||||
pendingRemTxs,
|
||||
expiredRemTxs
|
||||
}
|
||||
}
|
||||
|
||||
const partitionAddTransactions = (rawTransactions) => {
|
||||
const finalAddTxs = []
|
||||
const pendingAddTxs = []
|
||||
const finalAddTxs = []
|
||||
const pendingAddTxs = []
|
||||
const expiredAddTxs = []
|
||||
|
||||
for (const tx of rawTransactions) {
|
||||
if (tx.approvalStatus === 'PENDING') {
|
||||
pendingAddTxs.push(tx)
|
||||
} else {
|
||||
finalAddTxs.push(tx)
|
||||
}
|
||||
for (const tx of rawTransactions) {
|
||||
if (tx.approvalStatus === 'PENDING') {
|
||||
pendingAddTxs.push(tx)
|
||||
}
|
||||
else if (tx.approvalStatus === 'EXPIRED'){
|
||||
expiredAddTxs.push(tx)
|
||||
} else {
|
||||
finalAddTxs.push(tx)
|
||||
}
|
||||
}
|
||||
|
||||
return { finalAddTxs, pendingAddTxs };
|
||||
return { finalAddTxs, pendingAddTxs, expiredAddTxs };
|
||||
}
|
||||
|
||||
const partitionRemoveTransactions = (rawTransactions) => {
|
||||
const finalRemTxs = []
|
||||
const pendingRemTxs = []
|
||||
const finalRemTxs = []
|
||||
const pendingRemTxs = []
|
||||
const expiredRemTxs = []
|
||||
|
||||
for (const tx of rawTransactions) {
|
||||
if (tx.approvalStatus === 'PENDING') {
|
||||
for (const tx of rawTransactions) {
|
||||
if (tx.approvalStatus === 'PENDING') {
|
||||
pendingRemTxs.push(tx)
|
||||
} else {
|
||||
}
|
||||
else if (tx.approvalStatus === 'EXPIRED'){
|
||||
expiredRemTxs.push(tx)
|
||||
} else {
|
||||
finalRemTxs.push(tx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { finalRemTxs, pendingRemTxs }
|
||||
return { finalRemTxs, pendingRemTxs, expiredRemTxs }
|
||||
}
|
||||
|
||||
|
||||
@ -820,9 +842,9 @@ const createARCardHTML = async (cardData, pollResults, cardIdentifier, commentCo
|
||||
const actionsHtmlCheck = await checkAndDisplayActions(adminYes, verifiedName, cardIdentifier)
|
||||
actionsHtml = actionsHtmlCheck
|
||||
|
||||
const { finalAddTxs, pendingAddTxs, finalRemTxs, pendingRemTxs } = await fetchAllARTxData()
|
||||
const { finalAddTxs, pendingAddTxs, expiredAddTxs, finalRemTxs, pendingRemTxs, expiredRemTxs } = await fetchAllARTxData()
|
||||
|
||||
const confirmedAdd = finalAddTxs.some(
|
||||
const userConfirmedAdd = finalAddTxs.some(
|
||||
(tx) => tx.groupId === 694 && tx.member === accountAddress
|
||||
)
|
||||
const userPendingAdd = pendingAddTxs.some(
|
||||
@ -834,31 +856,88 @@ const createARCardHTML = async (cardData, pollResults, cardIdentifier, commentCo
|
||||
const userPendingRemove = pendingRemTxs.some(
|
||||
(tx) => tx.groupId === 694 && tx.admin === accountAddress
|
||||
)
|
||||
const userExpiredAdd = expiredAddTxs.some(
|
||||
(tx) => tx.groupId === 694 && tx.member === accountAddress
|
||||
)
|
||||
const userExpiredRem = expiredRemTxs.some(
|
||||
(tx) => tx.groupId === 694 && tx.admin === accountAddress
|
||||
)
|
||||
|
||||
const noExpired = (!userExpiredAdd && !userExpiredRem)
|
||||
|
||||
// If user is definitely admin (finalAdd) and not pending removal
|
||||
if (confirmedAdd && !userPendingRemove && existingAdmin) {
|
||||
if (userConfirmedAdd && !userPendingRemove && !userPendingAdd && noExpired && existingAdmin && promotionCard) {
|
||||
console.warn(`account was already admin, final. no add/remove pending.`);
|
||||
cardColorCode = 'rgb(3, 11, 24)'
|
||||
altText = `<h4 style="color:rgb(2, 94, 106); margin-bottom: 0.5em;">PROMOTED to ADMIN</h4>`;
|
||||
altText = `<h4 style="color:rgb(89, 191, 204); margin-bottom: 0.5em;">PROMOTED to ADMIN</h4>`;
|
||||
actionsHtml = ''
|
||||
}
|
||||
|
||||
if (confirmedAdd && userPendingRemove && existingAdmin) {
|
||||
console.warn(`user is a previously approved an admin, but now has pending removals. Keeping html`)
|
||||
if (userConfirmedAdd && !userPendingRemove && userExpiredRem && existingAdmin && promotionCard) {
|
||||
console.warn(`Account has previously had a removal attempt expire`);
|
||||
cardColorCode = 'rgb(33, 40, 11)'
|
||||
altText = `<h4 style="color:rgb(136, 114, 146); margin-bottom: 0.5em;">PROMOTED, (+Previous Failed Demotion).</h4>`;
|
||||
actionsHtml = ''
|
||||
}
|
||||
|
||||
// If user has a final "remove" and no pending additions or removals
|
||||
if (confirmedRemove && !userPendingAdd && existingMinter) {
|
||||
console.warn(`account was demoted, final. no add pending, existingMinter.`);
|
||||
if (userConfirmedAdd && !userPendingRemove && userExpiredAdd && existingAdmin && promotionCard) {
|
||||
console.warn(`Account has previously had a removal attempt expire`);
|
||||
cardColorCode = 'rgb(14, 3, 24)'
|
||||
altText = `<h4 style="color:rgb(114, 117, 146); margin-bottom: 0.5em;">PROMOTED, (+Previous Failed Promotion).</h4>`;
|
||||
actionsHtml = ''
|
||||
}
|
||||
|
||||
if (userConfirmedAdd && userPendingRemove && existingAdmin && noExpired && !promotionCard) {
|
||||
console.warn(`user is a previously approved an admin, but now has pending removals. Keeping html`)
|
||||
altText = `<h4 style="color:rgb(85, 34, 34); margin-bottom: 0.5em;">Pending REMOVAL in progress...</h4>`;
|
||||
}
|
||||
|
||||
if (userConfirmedAdd && userPendingRemove && existingAdmin && userExpiredAdd && !promotionCard) {
|
||||
console.warn(`user is a previously approved an admin, but now has pending removals. Keeping html`)
|
||||
altText = `<h4 style="color:rgb(85, 74, 34); margin-bottom: 0.5em;">Pending REMOVAL in progress... (+Previous Failed Promotion)</h4>`;
|
||||
}
|
||||
|
||||
if (userConfirmedAdd && userPendingRemove && existingAdmin && userExpiredRem && !promotionCard) {
|
||||
console.warn(`user is a previously approved an admin, but now has pending removals. Keeping html`)
|
||||
altText = `<h4 style="color:rgb(198, 26, 13); margin-bottom: 0.5em;">Pending REMOVAL in progress... (+Previous Failed Demotion)</h4>`;
|
||||
}
|
||||
|
||||
// If user has a final "remove" and no pending additions or removals and no expired transactions
|
||||
if (confirmedRemove && !userPendingAdd && existingMinter && !existingAdmin && noExpired && !promotionCard) {
|
||||
console.warn(`account was demoted, final. no add pending, existingMinter, no expired add/remove.`);
|
||||
cardColorCode = 'rgb(29, 4, 6)'
|
||||
altText = `<h4 style="color:rgb(73, 24, 24); margin-bottom: 0.5em;">DEMOTED from ADMIN</h4>`
|
||||
actionsHtml = ''
|
||||
}
|
||||
|
||||
if (confirmedRemove && !userPendingAdd && existingMinter && !existingAdmin && userExpiredRem && !promotionCard) {
|
||||
console.warn(`account was demoted, final. no add pending, existingMinter, no expired add/remove.`);
|
||||
cardColorCode = 'rgb(29, 4, 6)'
|
||||
altText = `<h4 style="color:rgb(170, 32, 48); margin-bottom: 0.5em;">DEMOTED (+Previous Failed Demotion)</h4>`
|
||||
actionsHtml = ''
|
||||
}
|
||||
|
||||
if (confirmedRemove && !userPendingAdd && existingMinter && !existingAdmin && userExpiredAdd && !promotionCard) {
|
||||
console.warn(`account was demoted, final. no add pending, existingMinter, no expired add/remove.`);
|
||||
cardColorCode = 'rgb(29, 4, 6)'
|
||||
altText = `<h4 style="color:rgb(119, 170, 32); margin-bottom: 0.5em;">DEMOTED (+Previous Failed Promotion)</h4>`
|
||||
actionsHtml = ''
|
||||
}
|
||||
|
||||
// If user has both final remove and pending add, do something else
|
||||
if (confirmedRemove && userPendingAdd && existingMinter) {
|
||||
if (confirmedRemove && userPendingAdd && existingMinter && noExpired && promotionCard) {
|
||||
console.warn(`account was previously demoted, but also a pending re-add, allowing actions to show...`)
|
||||
// Possibly show "DEMOTED but re-add in progress" or something
|
||||
altText = `<h4 style="color:rgb(73, 68, 24); margin-bottom: 0.5em;">Previously DEMOTED from ADMIN, attempted re-add in progress...</h4>`
|
||||
}
|
||||
|
||||
if (confirmedRemove && userPendingAdd && existingMinter && userExpiredAdd && promotionCard) {
|
||||
console.warn(`account was previously demoted, but also a pending re-add, allowing actions to show...`)
|
||||
altText = `<h4 style="color:rgb(73, 68, 24); margin-bottom: 0.5em;">Previously DEMOTED from ADMIN, attempted re-add in progress...(+Previous Failed Promotion)</h4>`
|
||||
}
|
||||
|
||||
if (confirmedRemove && userPendingAdd && existingMinter && userExpiredRem && promotionCard) {
|
||||
console.warn(`account was previously demoted, but also a pending re-add, allowing actions to show...`)
|
||||
altText = `<h4 style="color:rgb(73, 68, 24); margin-bottom: 0.5em;">Previously DEMOTED from ADMIN, attempted re-add in progress...(+Previous Failed Demotion)</h4>`
|
||||
}
|
||||
|
||||
} else if ( verifiedName && illegalDuplicate) {
|
||||
|
@ -72,8 +72,7 @@ const loadAdminBoardPage = async () => {
|
||||
mainContent.innerHTML = `
|
||||
<div class="minter-board-main" style="padding: 20px; text-align: center;">
|
||||
<h1 style="color: lightblue;">AdminBoard</h1>
|
||||
<p style="font-size: 1.25em;"> 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. </p>
|
||||
<p> 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'.</p>
|
||||
<p style="font-size: 0.95rem; color:rgba(255, 255, 255, 0.53)"> The Admin Board was meant to be utilized for DECISIONS regarding Minters or would-be Minters, and is encrypted to the Admins so that the data for the DECISIONS remains private. However, it later became the location to REMOVE minters as well. This, not being the original intended purpose has become problematic, as the removal data SHOULD be public. In the future, this data WILL be made public. The Admin Board will continue to be utilized for decision-making, but will NOT be a place for hidden removal data only. </p>
|
||||
<button id="publish-card-button" class="publish-card-button" style="margin: 20px; padding: 10px;">Publish Encrypted 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(70, 106, 105); background-color: black;">
|
||||
@ -84,12 +83,14 @@ const loadAdminBoardPage = async () => {
|
||||
<option value="most-votes">Most Votes</option>
|
||||
</select>
|
||||
<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>
|
||||
<option value="0">All Creation Dates</option>
|
||||
<option value="1">Last 1 Day</option>
|
||||
<option value="7">Last 7 Days</option>
|
||||
<option value="30">...Within 30 Days</option>
|
||||
<option value="45" selected>Published Within Last 45 Days</option>
|
||||
<option value="60">...Within 60 Days</option>
|
||||
<option value="90">...Within 90 Days</option>
|
||||
</select>
|
||||
<div class="show-card-checkbox" style="margin-top: 1em;">
|
||||
<input type="checkbox" id="admin-show-hidden-checkbox" name="adminHidden" />
|
||||
<label for="admin-show-hidden-checkbox">Show User-Hidden Cards?</label>
|
||||
@ -1078,7 +1079,7 @@ const createRemoveButtonHtml = (name, cardIdentifier) => {
|
||||
|
||||
const handleKickMinter = async (minterName) => {
|
||||
try {
|
||||
isAddress = await getAddressInfo(minterName)
|
||||
let isAddress = await getAddressInfo(minterName)
|
||||
|
||||
// Optional block check
|
||||
let txGroupId = 0
|
||||
@ -1091,7 +1092,7 @@ const handleKickMinter = async (minterName) => {
|
||||
|
||||
// Get the minter address from name info
|
||||
let minterAddress
|
||||
if (!isAddress){
|
||||
if (!isAddress.address || !isAddress.address != minterName){
|
||||
const minterNameInfo = await getNameInfo(minterName)
|
||||
minterAddress = minterNameInfo?.owner
|
||||
} else {
|
||||
@ -1107,7 +1108,7 @@ const handleKickMinter = async (minterName) => {
|
||||
const reason = 'Kicked by Minter Admins'
|
||||
const fee = 0.01
|
||||
|
||||
const rawKickTransaction = await createGroupKickTransaction(minterAddress, adminPublicKey, 694, minterAddress, reason, txGroupId, fee)
|
||||
const rawKickTransaction = await createGroupKickTransaction(adminPublicKey, 694, minterAddress, reason, txGroupId, fee)
|
||||
|
||||
const signedKickTransaction = await qortalRequest({
|
||||
action: "SIGN_TRANSACTION",
|
||||
@ -1138,7 +1139,7 @@ const handleKickMinter = async (minterName) => {
|
||||
}
|
||||
|
||||
const handleBanMinter = async (minterName) => {
|
||||
isAddress = await getAddressInfo(minterName)
|
||||
let isAddress = await getAddressInfo(minterName)
|
||||
try {
|
||||
let txGroupId = 0
|
||||
// const { height: currentHeight } = await getLatestBlockInfo()
|
||||
@ -1151,9 +1152,9 @@ const handleBanMinter = async (minterName) => {
|
||||
txGroupId = 694
|
||||
}
|
||||
let minterAddress
|
||||
if (!isAddress) {
|
||||
if (!isAddress.address || !isAddress.address != minterName){
|
||||
const minterNameInfo = await getNameInfo(minterName)
|
||||
const minterAddress = minterNameInfo?.owner
|
||||
minterAddress = minterNameInfo?.owner
|
||||
} else {
|
||||
minterAddress = minterName
|
||||
}
|
||||
|
@ -18,22 +18,23 @@ const loadMinterAdminToolsPage = async () => {
|
||||
mainContent.innerHTML = `
|
||||
<div class="tools-main mbr-parallax-background cid-ttRnlSkg2R">
|
||||
<div class="tools-header" style="color: white; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 10px;">
|
||||
<div><h1 style="font-size: 50px; margin: 0;">Admin Tools</h1></div>
|
||||
|
||||
<div class="user-info" style="border: 1px solid lightblue; padding: 5px; color: lightblue; display: flex; align-items: center; justify-content: center;">
|
||||
<img src="${avatarUrl}" alt="User Avatar" class="user-avatar" style="width: 50px; height: 50px; border-radius: 50%; margin-right: 10px;">
|
||||
<span>${userState.accountName || 'Guest'}</span>
|
||||
<span>${userState.accountName || 'Guest'}'s Admin Tools</span>
|
||||
</div>
|
||||
<div><h2>Welcome to Admin Tools</h2></div>
|
||||
|
||||
<div>
|
||||
<p>On this page you will find admin functionality for the Q-Mintership App. Including the 'blockList' for blocking comments from certain names, and manual creation of invite transactions.</p>
|
||||
<p>More features will be added as time goes on. This is the start of the functionality here.</p>
|
||||
<p style="color:rgba(80, 9, 9, 0.63)"></p>
|
||||
<p style="color:rgb(82, 114, 145)"> The approve feature allows invite by name, and shows ALL existing approvals ongoing, whether initiated on this page manually or not. Allowing for easy displaying and approving without loading the MinterBoard and scrolling through cards. </p>
|
||||
<p style="font-size: 0.85rem"> This is NOT a substitute for the AdminBoard, as obviously no data regarding the account is published here. However, if you have already read the data there, and wish to see it easily in one place, here, that is fine. It can also obviously be utilized to manually invite users that require such actions to be taken, however, this action as well should be extremely limited in usage, and not leveraged without extensive provided rationale.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tools-submenu" class="tools-submenu">
|
||||
<div class="tools-buttons" style="display: flex; gap: 1em; justify-content: center;">
|
||||
<button id="toggle-blocklist-button" class="publish-card-button">Add/Remove blockedUsers</button>
|
||||
<button id="create-group-invite" class="publish-card-button">Create Pending Group Invite</button>
|
||||
<button id="toggle-blocklist-button" style="background-color:rgba(80, 9, 9, 0.63)" class="publish-card-button">Add/Remove blockedUsers</button>
|
||||
<button id="create-group-invite" class="publish-card-button" style="background-color:rgb(82, 114, 145)">Create and Display Pending Group Invites</button>
|
||||
</div>
|
||||
|
||||
<div id="tools-window" class="tools-window" style="margin-top: 2em;">
|
||||
@ -56,6 +57,30 @@ const loadMinterAdminToolsPage = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="invite-container" class="invite-form" style="display: none; flex-direction: column; padding: 0.75em; align-items: center; justify-content: center;">
|
||||
|
||||
<!-- Existing pending invites display -->
|
||||
<div id="pending-invites-display" class="pending-invites-display" style="margin-bottom: 1em;">
|
||||
<!-- We will fill this dynamically with a list/table of pending invites -->
|
||||
</div>
|
||||
|
||||
<!-- Input for name/address -->
|
||||
<h3 style="margin-top: 0;">Manual Group Invite</h3>
|
||||
<input
|
||||
type="text"
|
||||
id="invite-input"
|
||||
class="invite-input"
|
||||
placeholder="Enter name or address to invite"
|
||||
style="margin-bottom: 1em;"
|
||||
/>
|
||||
|
||||
<!-- Button to create the invite transaction -->
|
||||
<div class="invite-button-container publish-card-form">
|
||||
<button id="invite-user-button" class="publish-card-button">Invite User</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -63,10 +88,10 @@ const loadMinterAdminToolsPage = async () => {
|
||||
|
||||
document.body.appendChild(mainContent)
|
||||
|
||||
addToolsPageEventListeners()
|
||||
await addToolsPageEventListeners()
|
||||
}
|
||||
|
||||
function addToolsPageEventListeners() {
|
||||
const addToolsPageEventListeners= async () => {
|
||||
document.getElementById("toggle-blocklist-button").addEventListener("click", async () => {
|
||||
const container = document.getElementById("blocklist-container")
|
||||
// toggle show/hide
|
||||
@ -116,6 +141,32 @@ function addToolsPageEventListeners() {
|
||||
alert(`"${nameToRemove}" removed from the block list (if it was present).`)
|
||||
})
|
||||
|
||||
document.getElementById("invite-user-button").addEventListener("click", async () => {
|
||||
const inviteInput = document.getElementById("invite-input")
|
||||
const nameOrAddress = inviteInput.value.trim()
|
||||
if (!nameOrAddress) return
|
||||
|
||||
try {
|
||||
// We'll call some function handleManualInvite(nameOrAddress)
|
||||
await handleManualInvite(nameOrAddress)
|
||||
inviteInput.value = ""
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error inviting user:", err)
|
||||
alert("Failed to invite user.")
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById("create-group-invite").addEventListener("click", async () => {
|
||||
const inviteContainer = document.getElementById("invite-container")
|
||||
// Toggle display
|
||||
inviteContainer.style.display = (inviteContainer.style.display === "none" ? "flex" : "none")
|
||||
// If showing, load the pending invites
|
||||
if (inviteContainer.style.display === "flex") {
|
||||
const pendingInvites = await fetchPendingInvites()
|
||||
await displayPendingInviteDetails(pendingInvites)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const displayBlockList = (blockedNames) => {
|
||||
@ -131,4 +182,139 @@ const displayBlockList = (blockedNames) => {
|
||||
`
|
||||
}
|
||||
|
||||
const fetchPendingInvites = async () => {
|
||||
try {
|
||||
const { finalInviteTxs, pendingInviteTxs } = await fetchAllInviteTransactions()
|
||||
return pendingInviteTxs
|
||||
} catch (err) {
|
||||
console.error("Error fetching pending invites:", err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualInvite = async (nameOrAddress) => {
|
||||
const addressInfo = await getAddressInfo(nameOrAddress)
|
||||
let address = addressInfo.address
|
||||
if (addressInfo && address) {
|
||||
console.log(`address is ${address}`)
|
||||
} else {
|
||||
// it might be a Qortal name => getNameInfo
|
||||
const nameData = await getNameInfo(nameOrAddress)
|
||||
if (!nameData || !nameData.owner) {
|
||||
throw new Error(`Cannot find valid address for ${nameOrAddress}`)
|
||||
}
|
||||
address = nameData.owner
|
||||
}
|
||||
|
||||
const adminPublicKey = await getPublicKeyByName(userState.accountName)
|
||||
const timeToLive = 864000 // e.g. 10 days in seconds
|
||||
const fee = 0.01
|
||||
let txGroupId = 694
|
||||
|
||||
// build the raw invite transaction
|
||||
const rawInviteTransaction = await createGroupInviteTransaction(
|
||||
address,
|
||||
adminPublicKey,
|
||||
694,
|
||||
address,
|
||||
timeToLive,
|
||||
txGroupId,
|
||||
fee
|
||||
)
|
||||
|
||||
// sign
|
||||
const signedTransaction = await qortalRequest({
|
||||
action: "SIGN_TRANSACTION",
|
||||
unsignedBytes: rawInviteTransaction
|
||||
})
|
||||
if (!signedTransaction) {
|
||||
throw new Error("SIGN_TRANSACTION returned null. Possibly user canceled or an older UI?")
|
||||
}
|
||||
|
||||
// process
|
||||
const processResponse = await processTransaction(signedTransaction)
|
||||
if (!processResponse) {
|
||||
throw new Error("Failed to process transaction. Possibly canceled or error from Qortal Core.")
|
||||
}
|
||||
|
||||
alert(`Invite transaction submitted for ${nameOrAddress}. Wait for confirmation.`)
|
||||
}
|
||||
|
||||
|
||||
const displayPendingInviteDetails = async (pendingInvites) => {
|
||||
const invitesContainer = document.getElementById('pending-invites-display')
|
||||
if (!pendingInvites || pendingInvites.length === 0) {
|
||||
invitesContainer.innerHTML = "<p>No pending invites found.</p>"
|
||||
return
|
||||
}
|
||||
|
||||
let html = `<h4>Current Pending Invites:</h4><div class="pending-invites-list">`
|
||||
|
||||
for (const inviteTx of pendingInvites) {
|
||||
const inviteeAddress = inviteTx.invitee
|
||||
const dateStr = new Date(inviteTx.timestamp).toLocaleString()
|
||||
let inviteeName = ""
|
||||
const txSig = inviteTx.signature
|
||||
const creatorName = await getNameFromAddress(inviteTx.creatorAddress)
|
||||
if (!creatorName) {
|
||||
creatorName = inviteTx.creatorAddress
|
||||
}
|
||||
|
||||
try {
|
||||
// fetch the name from address, if it fails we keep it blank or fallback to the address
|
||||
inviteeName = await getNameFromAddress(inviteeAddress)
|
||||
if (!inviteeName || inviteeName === inviteeAddress) {
|
||||
inviteeName = inviteeAddress // fallback
|
||||
}
|
||||
} catch (err) {
|
||||
inviteeName = inviteeAddress // fallback if getName fails
|
||||
}
|
||||
|
||||
const approvalSearchResults = await searchTransactions({
|
||||
txTypes: ['GROUP_APPROVAL'],
|
||||
confirmationStatus: 'CONFIRMED',
|
||||
limit: 0,
|
||||
reverse: false,
|
||||
offset: 0,
|
||||
startBlock: 1990000,
|
||||
blockLimit: 0,
|
||||
txGroupId: 0
|
||||
})
|
||||
|
||||
const approvals = approvalSearchResults.filter(
|
||||
(approvalTx) => approvalTx.pendingSignature === txSig
|
||||
)
|
||||
|
||||
const { tableHtml, approvalCount = approvals.length } = await buildApprovalTableHtml(approvals, getNameFromAddress)
|
||||
const finalTable = approvals.length > 0 ? tableHtml : "<p>No Approvals Found</p>"
|
||||
|
||||
html += `
|
||||
<div class="invite-item">
|
||||
<div class="invite-top-row">
|
||||
<span><strong>Invite Tx</strong>:<p style="color:lightblue"> ${inviteTx.signature.slice(0, 8)}...</p></span>
|
||||
<span> <strong>Invitee</strong>:<p style="color:lightblue"> ${inviteeName}</p></span>
|
||||
<span> <strong>Date</strong>:<p style="color:lightblue"> ${dateStr}</p></span>
|
||||
<span> <strong>CreatorName</strong>:<p style="color:lightblue"> ${creatorName}</p></span>
|
||||
<span> <strong>Total Approvals</strong>:<p style="color:lightblue"> ${approvalCount}</p></span>
|
||||
|
||||
</div>
|
||||
<!-- Next line for approvals -->
|
||||
<div class="invite-approvals">
|
||||
<strong>Existing Approvals:</strong>
|
||||
${finalTable}
|
||||
</div>
|
||||
<button
|
||||
class="approve-invite-list-button"
|
||||
onclick="handleGroupApproval('${inviteTx.signature}')"
|
||||
>
|
||||
Approve Invite
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
html += "</div>"
|
||||
invitesContainer.innerHTML = html
|
||||
}
|
||||
|
||||
|
||||
|
@ -9,6 +9,8 @@ const GROUP_APPROVAL_FEATURE_TRIGGER_HEIGHT = 2012800 //TODO update this to corr
|
||||
let featureTriggerPassed = false
|
||||
let isApproved = false
|
||||
|
||||
let cachedMinterAdmins
|
||||
let cachedMinterGroup
|
||||
|
||||
const loadMinterBoardPage = async () => {
|
||||
// Clear existing content on the page
|
||||
@ -25,42 +27,99 @@ const loadMinterBoardPage = async () => {
|
||||
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>
|
||||
<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>
|
||||
<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 class="minter-board-main" style="padding: 0.5vh; text-align: center;">
|
||||
|
||||
<!-- Board Title + Intro -->
|
||||
<h1 style="color: #527c9d;">The Minter Board</h1>
|
||||
<p style="font-size: 1.2em; color:rgb(85, 119, 101)">
|
||||
The Minter Board is where Minting Rights are Delegated.
|
||||
</p>
|
||||
<p style="font-size: 1.1em; color:rgb(85, 119, 119)">
|
||||
To obtain minting rights, click 'PUBLISH CARD' and create your card. A subsequent vote will approve/deny your card.
|
||||
</p>
|
||||
<p>
|
||||
After your card has received the necessary invite, return to the card and click the Join Group button to join the MINTER group.
|
||||
(A Detailed how-to guide will be coming soon.)
|
||||
</p>
|
||||
|
||||
<div class="card-display-options">
|
||||
<!-- Centered heading -->
|
||||
<h4 class="options-heading"style="color: #527c9d;">CARD DISPLAY OPTIONS</h4>
|
||||
|
||||
<!-- A flex container for all the controls (sort, time range, checkbox) -->
|
||||
<div class="options-row">
|
||||
<!-- Sort by -->
|
||||
<label for="sort-select" class="options-label">Sort By:</label>
|
||||
<select id="sort-select" class="options-select">
|
||||
<option value="newest" selected>Date</option>
|
||||
<option value="name">Name</option>
|
||||
<option value="recent-comments">Newest Comments</option>
|
||||
<option value="least-votes">Least Votes</option>
|
||||
<option value="most-votes">Most Votes</option>
|
||||
</select>
|
||||
|
||||
<!-- Time range -->
|
||||
<label for="time-range-select" class="options-label">Show Cards:</label>
|
||||
<select id="time-range-select" class="options-select">
|
||||
<option value="0">Show ALL Cards Published</option>
|
||||
<option value="1">...Within Last 1 Day</option>
|
||||
<option value="7">...Within Last 7 Days</option>
|
||||
<option value="30">...Within 30 Days</option>
|
||||
<option value="45" selected>Published Within Last 45 Days</option>
|
||||
<option value="60">...Within 60 Days</option>
|
||||
<option value="90">...Within 90 Days</option>
|
||||
</select>
|
||||
|
||||
<!-- Show existing checkbox -->
|
||||
<label class="options-check">
|
||||
<input type="checkbox" id="show-existing-checkbox" />
|
||||
Show Existing Minter Cards (History)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card counter heading centered, with actual counter below if desired -->
|
||||
<div style="margin-bottom: 1em;">
|
||||
<div style="text-align: center; margin-top: 0.5em;">
|
||||
<span id="board-card-counter" style="font-size: 1rem; color:rgb(153, 203, 204); padding: 0.5em;">
|
||||
<!-- e.g. "5 cards found" -->
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<!-- Row for Publish / Refresh actions -->
|
||||
<div class="card-actions" style="margin-bottom: 1em;">
|
||||
<button id="publish-card-button" class="publish-card-button">
|
||||
PUBLISH CARD
|
||||
</button>
|
||||
<button id="refresh-cards-button" class="refresh-cards-button"
|
||||
style="padding: 1vh;">
|
||||
REFRESH CARDS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Container for displayed cards -->
|
||||
<div id="cards-container" class="cards-container" style="margin-top: 2vh;"></div>
|
||||
|
||||
<!-- Hidden Publish Card Form -->
|
||||
<div id="publish-card-view" class="publish-card-view" style="display: none; text-align: left; padding: 2vh;">
|
||||
<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..." 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)
|
||||
@ -107,12 +166,19 @@ const loadMinterBoardPage = async () => {
|
||||
})
|
||||
|
||||
document.getElementById("refresh-cards-button").addEventListener("click", async () => {
|
||||
// Update the caches to include any new changes (e.g. new minters)
|
||||
await initializeCachedGroups()
|
||||
|
||||
// Optionally show a "refreshing" message
|
||||
const cardsContainer = document.getElementById("cards-container")
|
||||
cardsContainer.innerHTML = "<p>Refreshing cards...</p>"
|
||||
|
||||
// Then reload the cards with the updated cache data
|
||||
await loadCards(minterCardIdentifierPrefix)
|
||||
})
|
||||
|
||||
|
||||
|
||||
document.getElementById("cancel-publish-button").addEventListener("click", async () => {
|
||||
const cardsContainer = document.getElementById("cards-container")
|
||||
cardsContainer.style.display = "flex"; // Restore visibility
|
||||
@ -144,10 +210,48 @@ const loadMinterBoardPage = async () => {
|
||||
await loadCards(minterCardIdentifierPrefix)
|
||||
})
|
||||
|
||||
const showExistingCardsCheckbox = document.getElementById('show-existing-checkbox')
|
||||
if (showExistingCardsCheckbox) {
|
||||
showExistingCardsCheckbox.addEventListener('change', async (event) => {
|
||||
await loadCards(minterCardIdentifierPrefix)
|
||||
})
|
||||
}
|
||||
//Initialize Minter Group and Admin Group
|
||||
await initializeCachedGroups()
|
||||
|
||||
await featureTriggerCheck()
|
||||
await loadCards(minterCardIdentifierPrefix)
|
||||
}
|
||||
|
||||
const initializeCachedGroups = async () => {
|
||||
try {
|
||||
const [minterGroup, minterAdmins] = await Promise.all([
|
||||
fetchMinterGroupMembers(),
|
||||
fetchMinterGroupAdmins()
|
||||
])
|
||||
cachedMinterGroup = minterGroup
|
||||
cachedMinterAdmins = minterAdmins
|
||||
} catch (error) {
|
||||
console.error("Error initializing cached groups:", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const runWithConcurrency = async (tasks, concurrency = 5) => {
|
||||
const results = []
|
||||
let index = 0
|
||||
|
||||
const workers = new Array(concurrency).fill(null).map(async () => {
|
||||
while (index < tasks.length) {
|
||||
const currentIndex = index++
|
||||
const task = tasks[currentIndex]
|
||||
results[currentIndex] = await task()
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
const extractMinterCardsMinterName = async (cardIdentifier) => {
|
||||
// Ensure the identifier starts with the prefix
|
||||
@ -364,182 +468,258 @@ const processARBoardCards = async (allValidCards) => {
|
||||
|
||||
//Main function to load the Minter Cards ----------------------------------------
|
||||
const loadCards = async (cardIdentifierPrefix) => {
|
||||
if ((!cachedMinterGroup || cachedMinterGroup.length === 0) || (!cachedMinterAdmins || cachedMinterAdmins.length === 0)) {
|
||||
await initializeCachedGroups()
|
||||
}
|
||||
const cardsContainer = document.getElementById("cards-container")
|
||||
let isARBoard = false
|
||||
cardsContainer.innerHTML = "<p>Loading cards...</p>"
|
||||
|
||||
if (cardIdentifierPrefix.startsWith("QM-AR-card")) {
|
||||
isARBoard = true
|
||||
console.warn(`ARBoard determined:`, isARBoard)
|
||||
}
|
||||
const counterSpan = document.getElementById("board-card-counter")
|
||||
if (counterSpan) counterSpan.textContent = "(loading...)"
|
||||
|
||||
const isARBoard = cardIdentifierPrefix.startsWith("QM-AR-card")
|
||||
const showExistingCheckbox = document.getElementById("show-existing-checkbox")
|
||||
const showExisting = showExistingCheckbox && showExistingCheckbox.checked
|
||||
|
||||
let afterTime = 0
|
||||
const timeRangeSelect = document.getElementById("time-range-select")
|
||||
|
||||
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()}`)
|
||||
afterTime = now - days * 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const rawResults = await searchSimple('BLOG_POST', cardIdentifierPrefix, '', 0, 0, '', false, true, afterTime)
|
||||
if (!rawResults || !Array.isArray(rawResults) || rawResults.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) {
|
||||
const validated = (await Promise.all(
|
||||
rawResults.map(async (r) => (await validateCardStructure(r)) ? r : null)
|
||||
)).filter(Boolean)
|
||||
|
||||
if (validated.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')
|
||||
let processedCards
|
||||
if (isARBoard) {
|
||||
processedCards = await processARBoardCards(validated)
|
||||
} else {
|
||||
processedCards = await processMinterBoardCards(validated)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
if (selectedSort === "name") {
|
||||
processedCards.sort((a, b) => (a.name||"").localeCompare(b.name||""))
|
||||
} 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)
|
||||
// If you need the newest comment timestamp
|
||||
for (let card of finalCards) {
|
||||
card.newestCommentTimestamp = await getNewestCommentTimestamp(card.identifier)
|
||||
}
|
||||
// else 'newest' => do nothing (already sorted newest-first by your process functions).
|
||||
// Create the 'finalCardsArray' that includes the data, etc.
|
||||
let finalCardsArray = []
|
||||
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
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
if (!cardDataResponse || !cardDataResponse.poll) {
|
||||
// skip
|
||||
console.warn(`Skipping card: missing data/poll. identifier=${card.identifier}`)
|
||||
removeSkeleton(card.identifier)
|
||||
continue
|
||||
cardsContainer.innerHTML = "" // reset
|
||||
for (const card of processedCards) {
|
||||
const skeletonHTML = createSkeletonCardHTML(card.identifier)
|
||||
cardsContainer.insertAdjacentHTML("beforeend", skeletonHTML)
|
||||
}
|
||||
|
||||
const finalCardsArray = []
|
||||
const alreadyMinterCards = []
|
||||
|
||||
const tasks = processedCards.map(card => {
|
||||
return async () => {
|
||||
// We'll store an object with skip info, QDN data, etc.
|
||||
const result = {
|
||||
card,
|
||||
skip: false,
|
||||
skipReason: "",
|
||||
isAlreadyMinter: false,
|
||||
cardData: null,
|
||||
}
|
||||
// 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
|
||||
|
||||
try {
|
||||
const data = await qortalRequest({
|
||||
action: "FETCH_QDN_RESOURCE",
|
||||
name: card.name,
|
||||
service: "BLOG_POST",
|
||||
identifier: card.identifier
|
||||
})
|
||||
if (!data || !data.poll) {
|
||||
result.skip = true
|
||||
result.skipReason = "Missing or invalid poll"
|
||||
return result
|
||||
}
|
||||
} else {
|
||||
const isAlreadyMinter = await verifyMinter(cardDataResponse.creator)
|
||||
if (isAlreadyMinter) {
|
||||
console.warn(`card IS ALREADY a minter, NOT displaying following identifier on the MinterBoard: ${card.identifier}`)
|
||||
removeSkeleton(card.identifier)
|
||||
continue
|
||||
|
||||
const pollPublisherAddress = await getPollOwnerAddressCached(data.poll)
|
||||
const cardPublisherAddress = await fetchOwnerAddressFromNameCached(card.name)
|
||||
if (pollPublisherAddress !== cardPublisherAddress) {
|
||||
result.skip = true
|
||||
result.skipReason = "Poll hijack mismatch"
|
||||
return result
|
||||
}
|
||||
|
||||
// ARBoard => verify user is minter/admin
|
||||
if (isARBoard) {
|
||||
const ok = await verifyMinterCached(data.minterName)
|
||||
if (!ok) {
|
||||
result.skip = true
|
||||
result.skipReason = "Card user not minter => skip from ARBoard"
|
||||
return result
|
||||
}
|
||||
} else {
|
||||
// MinterBoard => skip if user is minter
|
||||
const isAlready = await verifyMinterCached(data.creator)
|
||||
if (isAlready) {
|
||||
result.skip = true
|
||||
result.skipReason = "Already a minter"
|
||||
result.isAlreadyMinter = true
|
||||
result.cardData = data
|
||||
return result
|
||||
}
|
||||
}
|
||||
// If we get here => it's a keeper
|
||||
result.cardData = data
|
||||
} catch (err) {
|
||||
console.warn("Error fetching resource or skip logic:", err)
|
||||
result.skip = true
|
||||
result.skipReason = "Error: " + err
|
||||
}
|
||||
// **Push** to finalCardsArray for further processing (duplicates, etc.)
|
||||
|
||||
return result
|
||||
}
|
||||
})
|
||||
// ADJUST THE CONCURRENCY TO INCREASE THE AMOUNT OF CARDS PROCESSED AT ONCE. INCREASE UNTIL THERE ARE ISSUES.
|
||||
const concurrency = 30
|
||||
const results = await runWithConcurrency(tasks, concurrency)
|
||||
|
||||
// Fill final arrays
|
||||
for (const r of results) {
|
||||
if (r.skip && r.isAlreadyMinter) {
|
||||
alreadyMinterCards.push({ ...r.card, cardDataResponse: r.cardData })
|
||||
removeSkeleton(r.card.identifier)
|
||||
} else if (r.skip) {
|
||||
console.warn(`Skipping card ${r.card.identifier}, reason=${r.skipReason}`)
|
||||
removeSkeleton(r.card.identifier)
|
||||
} else {
|
||||
// keeper
|
||||
finalCardsArray.push({
|
||||
...card,
|
||||
cardDataResponse,
|
||||
pollPublisherAddress,
|
||||
cardPublisherAddress,
|
||||
...r.card,
|
||||
cardDataResponse: r.cardData
|
||||
})
|
||||
} 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
|
||||
)
|
||||
try {
|
||||
const pollResults = await fetchPollResultsCached(cardObj.cardDataResponse.poll)
|
||||
const commentCount = await countCommentsCached(cardObj.identifier)
|
||||
const cardUpdatedTime = cardObj.updated || cardObj.created || null
|
||||
const bgColor = generateDarkPastelBackgroundBy(cardObj.name)
|
||||
|
||||
replaceSkeleton(cardObj.identifier, finalCardHTML)
|
||||
// If ARBoard => createARCardHTML else createCardHTML
|
||||
const finalCardHTML = isARBoard
|
||||
? await createARCardHTML(
|
||||
cardObj.cardDataResponse,
|
||||
pollResults,
|
||||
cardObj.identifier,
|
||||
commentCount,
|
||||
cardUpdatedTime,
|
||||
bgColor,
|
||||
await fetchOwnerAddressFromNameCached(cardObj.name),
|
||||
cardObj.isDuplicate
|
||||
)
|
||||
: await createCardHTML(
|
||||
cardObj.cardDataResponse,
|
||||
pollResults,
|
||||
cardObj.identifier,
|
||||
commentCount,
|
||||
cardUpdatedTime,
|
||||
bgColor,
|
||||
await fetchOwnerAddressFromNameCached(cardObj.name)
|
||||
)
|
||||
|
||||
replaceSkeleton(cardObj.identifier, finalCardHTML)
|
||||
} catch (err) {
|
||||
console.error(`Error finalizing card ${cardObj.identifier}:`, err)
|
||||
removeSkeleton(cardObj.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
if (showExisting && alreadyMinterCards.length > 0) {
|
||||
console.log(`Rendering minted cards because showExisting is checked, count=${alreadyMinterCards.length}`)
|
||||
for (const minted of alreadyMinterCards) {
|
||||
const skeletonHTML = createSkeletonCardHTML(minted.identifier)
|
||||
cardsContainer.insertAdjacentHTML("beforeend", skeletonHTML)
|
||||
|
||||
try {
|
||||
const pollResults = await fetchPollResultsCached(minted.cardDataResponse.poll)
|
||||
const commentCount = await countCommentsCached(minted.identifier)
|
||||
const cardUpdatedTime = minted.updated || minted.created || null
|
||||
const bgColor = generateDarkPastelBackgroundBy(minted.name)
|
||||
const finalCardHTML = await createCardHTML(
|
||||
minted.cardDataResponse,
|
||||
pollResults,
|
||||
minted.identifier,
|
||||
commentCount,
|
||||
cardUpdatedTime,
|
||||
bgColor,
|
||||
await fetchOwnerAddressFromNameCached(minted.name),
|
||||
/* isExistingMinter= */ true
|
||||
)
|
||||
replaceSkeleton(minted.identifier, finalCardHTML)
|
||||
} catch (err) {
|
||||
console.error(`Error finalizing minted card ${minted.identifier}:`, err)
|
||||
removeSkeleton(minted.identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (counterSpan) {
|
||||
const displayed = finalCardsArray.length
|
||||
const minted = alreadyMinterCards.length
|
||||
counterSpan.textContent = `(${displayed} displayed, ${minted} minters)`
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error loading cards:", error)
|
||||
cardsContainer.innerHTML = "<p>Failed to load cards.</p>"
|
||||
if (counterSpan) {
|
||||
counterSpan.textContent = "(error loading)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const verifyMinterCache = new Map()
|
||||
const verifyMinterCached = async (nameOrAddress) => {
|
||||
if (verifyMinterCache.has(nameOrAddress)) {
|
||||
return verifyMinterCache.get(nameOrAddress)
|
||||
}
|
||||
const result = await verifyMinter(nameOrAddress)
|
||||
verifyMinterCache.set(nameOrAddress, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const verifyMinter = async (minterName) => {
|
||||
try {
|
||||
const nameInfo = await getNameInfo(minterName)
|
||||
const nameInfo = await getNameInfoCached(minterName)
|
||||
|
||||
if (!nameInfo) return false
|
||||
const minterAddress = nameInfo.owner
|
||||
@ -547,8 +727,10 @@ const verifyMinter = async (minterName) => {
|
||||
|
||||
if (!isValid) return false
|
||||
// Then check if they're in the minter group
|
||||
const minterGroup = await fetchMinterGroupMembers()
|
||||
const adminGroup = await fetchMinterGroupAdmins()
|
||||
// const minterGroup = await fetchMinterGroupMembers()
|
||||
const minterGroup = cachedMinterGroup
|
||||
// const adminGroup = await fetchMinterGroupAdmins()
|
||||
const adminGroup = cachedMinterAdmins
|
||||
const minterGroupAddresses = minterGroup.map(m => m.member)
|
||||
const adminGroupAddresses = adminGroup.map(m => m.member)
|
||||
|
||||
@ -561,8 +743,10 @@ const verifyMinter = async (minterName) => {
|
||||
}
|
||||
|
||||
const applyVoteSortingData = async (cards, ascending = true) => {
|
||||
const minterGroupMembers = await fetchMinterGroupMembers()
|
||||
const minterAdmins = await fetchMinterGroupAdmins()
|
||||
// const minterGroupMembers = await fetchMinterGroupMembers()
|
||||
const minterGroupMembers = cachedMinterGroup
|
||||
// const minterAdmins = await fetchMinterGroupAdmins()
|
||||
const minterAdmins = cachedMinterAdmins
|
||||
|
||||
for (const card of cards) {
|
||||
try {
|
||||
@ -579,7 +763,7 @@ const applyVoteSortingData = async (cards, ascending = true) => {
|
||||
card._minterYes = 0
|
||||
continue
|
||||
}
|
||||
const pollResults = await fetchPollResults(cardDataResponse.poll);
|
||||
const pollResults = await fetchPollResultsCached(cardDataResponse.poll);
|
||||
const { adminYes, adminNo, minterYes, minterNo } = await processPollData(
|
||||
pollResults,
|
||||
minterGroupMembers,
|
||||
@ -750,8 +934,8 @@ const loadCardIntoForm = async (cardData) => {
|
||||
|
||||
// Main function to publish a new Minter Card -----------------------------------------------
|
||||
const publishCard = async (cardIdentifierPrefix) => {
|
||||
|
||||
const minterGroupData = await fetchMinterGroupMembers()
|
||||
// const minterGroupData = await fetchMinterGroupMembers()
|
||||
const minterGroupData = cachedMinterGroup
|
||||
const minterGroupAddresses = minterGroupData.map(m => m.member)
|
||||
const userAddress = userState.accountAddress
|
||||
|
||||
@ -759,6 +943,7 @@ const publishCard = async (cardIdentifierPrefix) => {
|
||||
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"))
|
||||
@ -770,8 +955,27 @@ const publishCard = async (cardIdentifierPrefix) => {
|
||||
return
|
||||
}
|
||||
|
||||
const cardIdentifier = isExistingCard ? existingCardIdentifier : `${cardIdentifierPrefix}-${await uid()}`
|
||||
const pollName = `${cardIdentifier}-poll`
|
||||
if (isExistingCard) {
|
||||
if (!existingCardData || Object.keys(existingCardData).length === 0) {
|
||||
const fetched = await fetchExistingCard(cardIdentifierPrefix)
|
||||
if (fetched) {
|
||||
existingCardData = fetched
|
||||
} else {
|
||||
console.warn("fetchExistingCard returned null. Possibly no existing card found.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cardIdentifier = isExistingCard && existingCardIdentifier
|
||||
? existingCardIdentifier
|
||||
: `${cardIdentifierPrefix}-${await uid()}`
|
||||
|
||||
let existingPollName
|
||||
if (existingCardData && existingCardData.poll) {
|
||||
existingPollName = existingCardData.poll
|
||||
}
|
||||
|
||||
const pollName = existingPollName || `${cardIdentifier}-poll`
|
||||
const pollDescription = `Mintership Board Poll for ${userState.accountName}`
|
||||
|
||||
const cardData = {
|
||||
@ -781,15 +985,15 @@ const publishCard = async (cardIdentifierPrefix) => {
|
||||
creator: userState.accountName,
|
||||
creatorAddress: userState.accountAddress,
|
||||
timestamp: Date.now(),
|
||||
poll: pollName,
|
||||
poll: pollName // either the existing poll or a new one
|
||||
}
|
||||
|
||||
try {
|
||||
let base64CardData = await objectToBase64(cardData)
|
||||
if (!base64CardData) {
|
||||
console.log(`initial base64 object creation with objectToBase64 failed, using btoa...`)
|
||||
base64CardData = btoa(JSON.stringify(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",
|
||||
@ -799,7 +1003,7 @@ const publishCard = async (cardIdentifierPrefix) => {
|
||||
data64: base64CardData,
|
||||
})
|
||||
|
||||
if (!isExistingCard){
|
||||
if (!isExistingCard || !existingPollName) {
|
||||
await qortalRequest({
|
||||
action: "CREATE_POLL",
|
||||
pollName,
|
||||
@ -807,26 +1011,33 @@ const publishCard = async (cardIdentifierPrefix) => {
|
||||
pollOptions: ['Yes, No'],
|
||||
pollOwnerAddress: userState.accountAddress,
|
||||
})
|
||||
alert("Card and poll published successfully!")
|
||||
if (!isExistingCard) {
|
||||
alert("Card and poll published successfully!")
|
||||
} else {
|
||||
alert("Existing card updated, and new poll created (since existing poll was missing)!")
|
||||
}
|
||||
} else {
|
||||
alert("Card updated successfully! (No poll updates possible)")
|
||||
}
|
||||
|
||||
if (isExistingCard){
|
||||
alert("Card Updated Successfully! (No poll updates possible)")
|
||||
if (isExistingCard) {
|
||||
isExistingCard = false
|
||||
existingCardData = {}
|
||||
}
|
||||
|
||||
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) => {
|
||||
@ -1193,6 +1404,7 @@ const toggleComments = async (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)
|
||||
|
||||
@ -1210,6 +1422,16 @@ const toggleComments = async (cardIdentifier) => {
|
||||
}
|
||||
}
|
||||
|
||||
const commentCountCache = new Map()
|
||||
const countCommentsCached= async (cardIdentifier) => {
|
||||
if (commentCountCache.has(cardIdentifier)) {
|
||||
return commentCountCache.get(cardIdentifier)
|
||||
}
|
||||
const count = await countComments(cardIdentifier)
|
||||
commentCountCache.set(cardIdentifier, count)
|
||||
return count
|
||||
}
|
||||
|
||||
const countComments = async (cardIdentifier) => {
|
||||
try {
|
||||
const response = await searchSimple('BLOG_POST', `comment-${cardIdentifier}`, '', 0, 0, '', 'false')
|
||||
@ -1360,7 +1582,7 @@ const handleInviteMinter = async (minterName) => {
|
||||
try {
|
||||
const blockInfo = await getLatestBlockInfo()
|
||||
const blockHeight = blockInfo.height
|
||||
const minterAccountInfo = await getNameInfo(minterName)
|
||||
const minterAccountInfo = await getNameInfoCached(minterName)
|
||||
const minterAddress = await minterAccountInfo.owner
|
||||
let adminPublicKey
|
||||
let txGroupId
|
||||
@ -1444,7 +1666,8 @@ const featureTriggerCheck = async () => {
|
||||
const checkAndDisplayInviteButton = async (adminYes, creator, cardIdentifier) => {
|
||||
const isSomeTypaAdmin = userState.isAdmin || userState.isMinterAdmin
|
||||
const isBlockPassed = await featureTriggerCheck()
|
||||
const minterAdmins = await fetchMinterGroupAdmins()
|
||||
// const minterAdmins = await fetchMinterGroupAdmins()
|
||||
const minterAdmins = cachedMinterAdmins
|
||||
|
||||
// default needed admin count = 9, or 40% if block has passed
|
||||
let minAdminCount = 9
|
||||
@ -1460,7 +1683,7 @@ const checkAndDisplayInviteButton = async (adminYes, creator, cardIdentifier) =>
|
||||
}
|
||||
console.log(`passed initial button creation checks (adminYes >= ${minAdminCount})`)
|
||||
// get user's address from 'creator' name
|
||||
const minterNameInfo = await getNameInfo(creator)
|
||||
const minterNameInfo = await getNameInfoCached(creator)
|
||||
if (!minterNameInfo || !minterNameInfo.owner) {
|
||||
console.warn(`No valid nameInfo for ${creator}, skipping invite button.`)
|
||||
return null
|
||||
@ -1469,7 +1692,7 @@ const checkAndDisplayInviteButton = async (adminYes, creator, cardIdentifier) =>
|
||||
// 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
|
||||
// check if there's a 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)
|
||||
@ -1480,10 +1703,12 @@ const checkAndDisplayInviteButton = async (adminYes, creator, cardIdentifier) =>
|
||||
// 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})`)
|
||||
console.warn(`There is an EXISTING or PENDING INVITE for this user! No invite button being created... existing: (${existingInvite}, pending: ${pendingInvite})`)
|
||||
inviteButtonHtml = ''
|
||||
} else {
|
||||
inviteButtonHtml = isSomeTypaAdmin ? createInviteButtonHtml(creator, cardIdentifier) : ""
|
||||
}
|
||||
inviteButtonHtml = isSomeTypaAdmin ? createInviteButtonHtml(creator, cardIdentifier) : ""
|
||||
|
||||
const groupApprovalHtml = await checkGroupApprovalAndCreateButton(minterAddress, cardIdentifier, "GROUP_INVITE")
|
||||
|
||||
// if user had no prior KICK/BAN
|
||||
@ -1507,10 +1732,8 @@ const checkAndDisplayInviteButton = async (adminYes, creator, cardIdentifier) =>
|
||||
}
|
||||
}
|
||||
|
||||
const findPendingApprovalTxForAddress = async (address, txType, limit = 0, offset = 0) => {
|
||||
// 1) Fetch all pending transactions
|
||||
const findPendingTxForAddress = async (address, txType, limit = 0, offset = 0) => {
|
||||
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])
|
||||
@ -1547,7 +1770,8 @@ const checkGroupApprovalAndCreateButton = async (address, cardIdentifier, transa
|
||||
// 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 minterMembers = await fetchMinterGroupMembers()
|
||||
const minterMembers = cachedMinterGroup
|
||||
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`)
|
||||
@ -1565,15 +1789,15 @@ const checkGroupApprovalAndCreateButton = async (address, cardIdentifier, transa
|
||||
blockLimit: 0,
|
||||
txGroupId: 0
|
||||
})
|
||||
const pendingApprovals = await findPendingApprovalTxForAddress(address, transactionType, 0, 0)
|
||||
const pendingTxs = await findPendingTxForAddress(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...")
|
||||
if (!pendingTxs || pendingTxs.length === 0) {
|
||||
console.warn("no pending transactions found, returning null...")
|
||||
return null
|
||||
}
|
||||
const txSig = pendingApprovals[0].signature
|
||||
// Find the relevant signature. (First approval)
|
||||
const txSig = pendingTxs[0].signature
|
||||
// Find the relevant signature. (signature of the issued transaction pending.)
|
||||
const relevantApprovals = approvalSearchResults.filter(
|
||||
(approvalTx) => approvalTx.pendingSignature === txSig
|
||||
)
|
||||
@ -1903,7 +2127,7 @@ const getNewestCommentTimestamp = async (cardIdentifier) => {
|
||||
}
|
||||
|
||||
// Create the overall Minter Card HTML -----------------------------------------------
|
||||
const createCardHTML = async (cardData, pollResults, cardIdentifier, commentCount, cardUpdatedTime, bgColor, address) => {
|
||||
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)
|
||||
@ -1913,13 +2137,15 @@ const createCardHTML = async (cardData, pollResults, cardIdentifier, commentCoun
|
||||
</button>
|
||||
`).join("")
|
||||
|
||||
const minterGroupMembers = await fetchMinterGroupMembers()
|
||||
const minterAdmins = await fetchMinterGroupAdmins()
|
||||
// const minterGroupMembers = await fetchMinterGroupMembers()
|
||||
const minterGroupMembers = cachedMinterGroup
|
||||
// const minterAdmins = await fetchMinterGroupAdmins()
|
||||
const minterAdmins = cachedMinterAdmins
|
||||
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 = await checkAndDisplayInviteButton(adminYes, creator, cardIdentifier)
|
||||
const inviteButtonHtml = isExistingMinter ? "" : await checkAndDisplayInviteButton(adminYes, creator, cardIdentifier)
|
||||
let inviteHtmlAdd = (inviteButtonHtml) ? inviteButtonHtml : ''
|
||||
|
||||
let finalBgColor = bgColor
|
||||
@ -1935,6 +2161,9 @@ const createCardHTML = async (cardData, pollResults, cardIdentifier, commentCoun
|
||||
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";
|
||||
|
@ -1,4 +1,4 @@
|
||||
const Q_MINTERSHIP_VERSION = "1.06"
|
||||
const Q_MINTERSHIP_VERSION = "1.22"
|
||||
|
||||
const messageIdentifierPrefix = `mintership-forum-message`
|
||||
const messageAttachmentIdentifierPrefix = `mintership-forum-attachment`
|
||||
|
@ -6,6 +6,11 @@ let baseUrl = ''
|
||||
let isOutsideOfUiDevelopment = false
|
||||
let nullAddress = 'QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG'
|
||||
|
||||
// Caching to improve performance
|
||||
const nameInfoCache = new Map(); // name -> nameInfo
|
||||
const addressInfoCache = new Map(); // address -> addressInfo
|
||||
const pollResultsCache = new Map(); // pollName -> pollResults
|
||||
|
||||
if (typeof qortalRequest === 'function') {
|
||||
console.log('qortalRequest is available as a function. Setting development mode to false and baseUrl to nothing.')
|
||||
isOutsideOfUiDevelopment = false
|
||||
@ -223,7 +228,20 @@ const getUserAddress = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getAddressInfoCached = async (address) => {
|
||||
if (addressInfoCache.has(address)) return addressInfoCache.get(address)
|
||||
const result = await getAddressInfo(address)
|
||||
addressInfoCache.set(address, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const getAddressInfo = async (address) => {
|
||||
const qortalAddressPattern = /^Q[A-Za-z0-9]{33}$/ // Q + 33 almum = 34 total length
|
||||
|
||||
if (!qortalAddressPattern.test(address)) {
|
||||
console.warn(`Not a valid Qortal address format, returning same thing that was passed to not break other functions: ${address}`)
|
||||
return address
|
||||
}
|
||||
try {
|
||||
const response = await fetch (`${baseUrl}/addresses/${address}`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
@ -248,6 +266,19 @@ const getAddressInfo = async (address) => {
|
||||
}
|
||||
}
|
||||
|
||||
const nameToAddressCache = new Map()
|
||||
const fetchOwnerAddressFromNameCached = async (name) => {
|
||||
if (nameToAddressCache.has(name)) {
|
||||
return nameToAddressCache.get(name)
|
||||
}
|
||||
|
||||
const address = await fetchOwnerAddressFromName(name)
|
||||
|
||||
nameToAddressCache.set(name, address)
|
||||
return address
|
||||
}
|
||||
|
||||
|
||||
const fetchOwnerAddressFromName = async (name) => {
|
||||
console.log('fetchOwnerAddressFromName called')
|
||||
console.log('name:', name)
|
||||
@ -327,6 +358,15 @@ const verifyAddressIsAdmin = async (address) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getNameInfoCached = async (name) => {
|
||||
if (nameInfoCache.has(name)) {
|
||||
return nameInfoCache.get(name)
|
||||
}
|
||||
const result = await getNameInfo(name)
|
||||
nameInfoCache.set(name, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const getNameInfo = async (name) => {
|
||||
console.log('getNameInfo called')
|
||||
console.log('name:', name)
|
||||
@ -786,18 +826,20 @@ const searchSimple = async (service, identifier, name, limit=1500, offset=0, roo
|
||||
|
||||
if (name && !identifier && !room) {
|
||||
console.log('name only searchSimple', name)
|
||||
urlSuffix = `service=${service}&name=${name}&limit=${limit}&prefix=true&reverse=${reverse}`
|
||||
urlSuffix = `service=${service}&name=${name}&limit=${limit}&prefix=true&reverse=${reverse}&after=${after}`
|
||||
console.log(`urlSuffix used: ${urlSuffix}`)
|
||||
|
||||
} else if (!name && identifier && !room) {
|
||||
console.log('identifier only searchSimple', identifier)
|
||||
urlSuffix = `service=${service}&identifier=${identifier}&limit=${limit}&prefix=true&reverse=${reverse}`
|
||||
urlSuffix = `service=${service}&identifier=${identifier}&limit=${limit}&prefix=true&reverse=${reverse}&after=${after}`
|
||||
console.log(`urlSuffix used: ${urlSuffix}`)
|
||||
|
||||
} else if (!name && !identifier && !room) {
|
||||
console.error(`name: ${name} AND identifier: ${identifier} not passed. Must include at least one...`)
|
||||
return null
|
||||
|
||||
} else {
|
||||
console.log(`final searchSimple params = service: '${service}', identifier: '${identifier}', name: '${name}', limit: '${limit}', offset: '${offset}', room: '${room}', reverse: '${reverse}'`)
|
||||
console.log(`final searchSimple params = service: '${service}', identifier: '${identifier}', name: '${name}', limit: '${limit}', offset: '${offset}', room: '${room}', reverse: '${reverse}', after: ${after}`)
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/arbitrary/resources/searchsimple?${urlSuffix}`, {
|
||||
@ -1231,6 +1273,20 @@ const getProductDetails = async (service, name, identifier) => {
|
||||
|
||||
// Qortal poll-related calls ----------------------------------------------------------------------
|
||||
|
||||
const pollOwnerAddrCache = new Map()
|
||||
|
||||
const getPollOwnerAddressCached = async (pollName) => {
|
||||
if (pollOwnerAddrCache.has(pollName)) {
|
||||
return pollOwnerAddrCache.get(pollName)
|
||||
}
|
||||
|
||||
const ownerAddress = await getPollOwnerAddress(pollName)
|
||||
|
||||
// Store in cache
|
||||
pollOwnerAddrCache.set(pollName, ownerAddress)
|
||||
return ownerAddress
|
||||
}
|
||||
|
||||
const getPollOwnerAddress = async (pollName) => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/polls/${pollName}`, {
|
||||
@ -1259,6 +1315,15 @@ const getPollPublisherPublicKey = async (pollName) => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPollResultsCached = async (pollName) => {
|
||||
if (pollResultsCache.has(pollName)) {
|
||||
return pollResultsCache.get(pollName)
|
||||
}
|
||||
const result = await fetchPollResults(pollName)
|
||||
pollResultsCache.set(pollName, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const fetchPollResults = async (pollName) => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/polls/votes/${pollName}`, {
|
||||
@ -1346,7 +1411,7 @@ const processTransaction = async (signedTransaction) => {
|
||||
|
||||
// Create a group invite transaction. This will utilize a default timeToLive (which is how long the tx will be alive, not the time until it IS live...) of 10 days in seconds, as the legacy UI has a bug that doesn't display invites older than 10 days.
|
||||
// We will also default to the MINTER group for groupId, AFTER the GROUP_APPROVAL changes, the txGroupId will need to be set for tx that require approval.
|
||||
const createGroupInviteTransaction = async (recipientAddress, adminPublicKey, groupId=694, invitee, timeToLive, txGroupId, fee) => {
|
||||
const createGroupInviteTransaction = async (recipientAddress, adminPublicKey, groupId=694, invitee, timeToLive=0, txGroupId, fee) => {
|
||||
|
||||
try {
|
||||
// Fetch account reference correctly
|
||||
@ -1395,16 +1460,16 @@ const createGroupInviteTransaction = async (recipientAddress, adminPublicKey, gr
|
||||
}
|
||||
}
|
||||
|
||||
const createGroupKickTransaction = async (recipientAddress, adminPublicKey, groupId=694, member, reason='Kicked by admins', txGroupId, fee) => {
|
||||
const createGroupKickTransaction = async (adminPublicKey, groupId=694, member, reason='Kicked by admins', txGroupId=694, fee=0.01) => {
|
||||
|
||||
try {
|
||||
// Fetch account reference correctly
|
||||
const accountInfo = await getAddressInfo(recipientAddress)
|
||||
const accountInfo = await getAddressInfo(member)
|
||||
const accountReference = accountInfo.reference
|
||||
|
||||
// Validate inputs before making the request
|
||||
if (!adminPublicKey || !accountReference || !recipientAddress) {
|
||||
throw new Error("Missing required parameters for group invite transaction.")
|
||||
if (!adminPublicKey || !accountReference || !member) {
|
||||
throw new Error("Missing required parameters for group kick transaction.")
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@ -1412,11 +1477,10 @@ const createGroupKickTransaction = async (recipientAddress, adminPublicKey, grou
|
||||
reference: accountReference,
|
||||
fee,
|
||||
txGroupId,
|
||||
recipient: null,
|
||||
adminPublicKey,
|
||||
groupId: groupId,
|
||||
member: member || recipientAddress,
|
||||
reason: reason
|
||||
groupId,
|
||||
member,
|
||||
reason
|
||||
}
|
||||
|
||||
console.log("Sending GROUP_KICK transaction payload:", payload)
|
||||
|
@ -196,8 +196,8 @@ const fetchAllInviteTransactions = async () => {
|
||||
|
||||
const { finalTx: finalInviteTxs, pendingTx: pendingInviteTxs } = partitionTransactions(allInviteTx)
|
||||
|
||||
console.log('Final kickTxs:', finalInviteTxs)
|
||||
console.log('Pending kickTxs:', pendingInviteTxs)
|
||||
console.log('Final InviteTxs:', finalInviteTxs)
|
||||
console.log('Pending InviteTxs:', pendingInviteTxs)
|
||||
|
||||
return {
|
||||
finalInviteTxs,
|
||||
@ -205,4 +205,16 @@ const fetchAllInviteTransactions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const findPendingApprovalsForTxSignature = async (txSignature, txType='GROUP_APPROVAL', limit=0, offset=0) => {
|
||||
const pendingTxs = await searchPendingTransactions(limit, offset)
|
||||
|
||||
// Filter only the relevant GROUP_APPROVAL TX referencing txSignature
|
||||
const approvals = pendingTxs.filter(tx =>
|
||||
tx.type === txType && tx.pendingSignature === txSignature
|
||||
)
|
||||
console.log(`approvals found:`,approvals)
|
||||
return approvals
|
||||
}
|
||||
|
||||
|
||||
|
@ -141,7 +141,7 @@
|
||||
<div class="row">
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card-wrapper" style="justify-content:center; align: center; align-text: center;">
|
||||
<div class="card-wrapper" style="justify-content:center;">
|
||||
<div class="icon-wrapper">
|
||||
<span class="mbr-iconfont mbr-iconfont-btn mbri-file" style="color:aliceblue;"></span>
|
||||
</div>
|
||||
@ -200,14 +200,14 @@
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-7 card">
|
||||
<div class="title-wrapper">
|
||||
<h2 class="mbr-section-title mbr-fonts-style display-2">
|
||||
v1.06beta 01-31-2025</h2>
|
||||
<h2 class="mbr-section-title mbr-fonts-style display-2 version">
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-5 card">
|
||||
<div class="text-wrapper">
|
||||
<p class="mbr-text mbr-fonts-style display-7">
|
||||
<b><u>v1.06b Fixes</u></b>- <b>EMERGENCY UPDATE </b> - See post in the <a href="MINTERSHIP-FORUM">FORUM</a> for RELEASE NOTES, This is an emergency update that is meant to prevent the issue that took place yesterday and ended up stalling quite a few nodes. This means that Q-Mintership should be the ONLY APP UTILIZED FOR THE FUNCTIONALITY IT PROVIDES.
|
||||
<!-- <b class="version"><u>v1.06.4b</u></b>- <b>various improvements</b> - See post in the <a href="MINTERSHIP-FORUM">FORUM</a> for RELEASE NOTES. -->
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
Loading…
x
Reference in New Issue
Block a user