Contractless-GUI-Wallet/frontend/js/nfts_page.js

598 lines
18 KiB
JavaScript

let ownedNfts = [];
let ownedNftRows = [];
let selectedNftKey = "";
let nftsLoadId = 0;
let nftsLoading = false;
let nftsPage = 1;
let nftsCachingVisible = false;
const NFTS_PAGE_SIZE = 30;
const nftMetadataCache = new Map();
function nftKey(nft) {
return `${String(nft.nft_name || nft.asset || "").trim().toLowerCase()}|${Number(nft.series || nft.nft_series || 0)}`;
}
function nftCacheKey(nft) {
return `${String(nft.nft_name || nft.asset || "").trim().toLowerCase()}_${Number(nft.series || nft.nft_series || 0)}`;
}
function escapeNftText(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function shortenNftTx(txid) {
const value = String(txid || "");
return value.length > 16 ? `${value.slice(0, 6)}...${value.slice(-8)}` : value || "--";
}
function nftDisplayName(nft) {
const metadataName = String(nft.metadata?.name || "").trim();
if (metadataName) {
return metadataName;
}
const name = String(nft.nft_name || nft.asset || "NFT").trim();
return Number(nft.series || 0) > 0 ? `${name} #${Number(nft.series)}` : name;
}
function nftImageUri(nft) {
const cachedImage = String(nft.image_data_url || nft.imageDataUrl || "").trim();
if (cachedImage) {
return cachedImage;
}
return String(
nft.metadata?.image
|| nft.metadata?.image_url
|| nft.metadata?.animation_url
|| ""
).trim();
}
function normalizeIpfsGateway(gateway) {
const fallback = "https://gateway.pinata.cloud/ipfs/";
const clean = String(gateway || "").trim() || fallback;
return clean.endsWith("/") ? clean : `${clean}/`;
}
function normalizeIpfsPath(value) {
let path = String(value || "").trim();
let previous = "";
while (path && path !== previous) {
previous = path;
path = path
.replace(/^ipfs:\/\//i, "")
.replace(/^ipfs:\//i, "")
.replace(/^ipfs:/i, "")
.replace(/^\/?ipfs\//i, "")
.replace(/^\/+/, "");
}
return path;
}
function ipfsToGatewayUrl(uri, gateway) {
const value = String(uri || "").trim();
if (!value) {
return "";
}
if (value.startsWith("http://") || value.startsWith("https://")) {
return value;
}
if (value.startsWith("data:image/")) {
return value;
}
const path = normalizeIpfsPath(value);
return `${normalizeIpfsGateway(gateway)}${path}`;
}
function nftTypeLabel(nft) {
const metadataType = String(nft.metadata?.contractless?.metadata_type || "").trim();
if (metadataType.toLowerCase() === "rwa") {
return "RWA";
}
if (nftImageUri(nft)) {
return "Image NFT";
}
return "NFT";
}
function setNftsState(message) {
if (nftsState) {
nftsState.textContent = message || "";
}
}
function setNftDetailText(element, value) {
if (element) {
element.textContent = value || "--";
}
}
function resetNftDetails(message = "Select an owned NFT.") {
if (nftDetailTitle) {
nftDetailTitle.textContent = "NFT Details";
}
if (nftDetailSubtitle) {
nftDetailSubtitle.textContent = message;
}
setNftDetailText(nftDetailName, "");
setNftDetailText(nftDetailAsset, "");
setNftDetailText(nftDetailCreator, "");
setNftDetailText(nftDetailGenesis, "");
setNftDetailText(nftDetailMetadata, "");
if (nftMetadataSummary) {
nftMetadataSummary.innerHTML = "";
}
if (nftHistory) {
nftHistory.innerHTML = "";
}
}
function hasOwnedBalance(balance) {
try {
return BigInt(String(balance?.balance || "0")) > 0n;
} catch (_) {
return Number(balance?.balance || 0) > 0;
}
}
function ownedNftRowsFromBalances(balances, registry) {
const normalizedBalances = Array.isArray(balances) ? balances : [];
const registryRows = Array.isArray(registry) ? registry : [];
const balanceByAsset = new Map();
normalizedBalances.forEach((balance) => {
if (!hasOwnedBalance(balance)) {
return;
}
const asset = String(balance.asset || "").trim().toLowerCase();
const series = Number(balance.nft_series || 0);
balanceByAsset.set(`${asset}|${series}`, balance);
});
return registryRows
.map((nft) => {
const nftName = String(nft.nft_name || "").trim();
const series = Number(nft.series || 0);
const balance = balanceByAsset.get(`${nftName.toLowerCase()}|${series}`);
if (!balance) {
return null;
}
return {
asset: nftName,
nft_name: nftName,
series,
balance: String(balance.balance || "0"),
origin_txid: nft.origin_txid || ""
};
})
.filter(Boolean)
.filter((nft) => nft.nft_name);
}
function legacyOwnedNftRowsFromBalances(balances) {
return (Array.isArray(balances) ? balances : [])
.filter((balance) => {
const series = Number(balance.nft_series || 0);
let owned = false;
try {
owned = BigInt(String(balance.balance || "0")) > 0n;
} catch (_) {
owned = Number(balance.balance || 0) > 0;
}
return series > 0 && owned;
})
.map((balance) => ({
asset: String(balance.asset || "").trim(),
nft_name: String(balance.asset || "").trim(),
series: Number(balance.nft_series || 0),
balance: String(balance.balance || "0")
}))
.filter((nft) => nft.nft_name);
}
function metadataDescription(metadata) {
return String(metadata?.description || metadata?.summary || "").trim();
}
function renderNftCard(nft) {
const key = nftKey(nft);
const name = nftDisplayName(nft);
const image = nftImageUri(nft);
const type = nftTypeLabel(nft);
const active = key === selectedNftKey ? " active" : "";
const preview = image && nft.gateway
? `<img src="${escapeNftText(ipfsToGatewayUrl(image, nft.gateway))}" alt="">`
: `<div class="asset-mark art">${escapeNftText(assetInitial(name))}</div>`;
return `
<article class="nft-record${active}" data-nft-key="${escapeNftText(key)}">
<div class="nft-card-media">${preview}</div>
<div class="nft-record-top">
<div>
<strong>${escapeNftText(name)}</strong>
<span>${escapeNftText(type)} - ${escapeNftText(nft.asset_name || `${nft.nft_name}_${nft.series}`)}</span>
</div>
</div>
<div class="nft-link-row">
<span>Metadata</span>
<code>${escapeNftText(nft.metadata_uri || nft.item_ipfs || "--")}</code>
</div>
<div class="nft-actions">
<button class="secondary-button" type="button" data-nft-select="${escapeNftText(key)}">View Details</button>
</div>
</article>
`;
}
function renderNftHistoryEntry(entry) {
const asset = entry.received_asset
? `${entry.received_asset}${Number(entry.received_series || 0) > 0 ? ` #${entry.received_series}` : ""}`
: "";
return `
<div class="nft-history-row">
<div>
<b>${escapeNftText(entry.action || "Unknown")}</b>
<span>Block ${escapeNftText(formatNumber(entry.block || 0))}</span>
</div>
<code>${escapeNftText(shortenNftTx(entry.txid || ""))}</code>
${entry.from ? `<div><span>From</span><strong>${escapeNftText(shortenAddress(entry.from))}</strong></div>` : ""}
${entry.to ? `<div><span>To</span><strong>${escapeNftText(shortenAddress(entry.to))}</strong></div>` : ""}
${asset ? `<div><span>Received</span><strong>${escapeNftText(asset)}</strong></div>` : ""}
</div>
`;
}
function renderNftMetadataSummary(nft) {
const metadata = nft.metadata || {};
const description = metadataDescription(metadata);
const attributes = Array.isArray(metadata.attributes) ? metadata.attributes : [];
const contractless = metadata.contractless && typeof metadata.contractless === "object"
? metadata.contractless
: null;
const parts = [];
if (description) {
parts.push(`<p>${escapeNftText(description)}</p>`);
}
if (attributes.length) {
parts.push(`
<div class="nft-attributes">
${attributes.slice(0, 8).map((attribute) => `
<div>
<span>${escapeNftText(attribute.trait_type || attribute.type || "Trait")}</span>
<b>${escapeNftText(attribute.value ?? "")}</b>
</div>
`).join("")}
</div>
`);
}
if (contractless) {
parts.push(`
<div class="nft-rwa-fields">
${Object.entries(contractless).slice(0, 6).map(([key, value]) => `
<div><span>${escapeNftText(key)}</span><b>${escapeNftText(value)}</b></div>
`).join("")}
</div>
`);
}
return parts.join("") || "<p>No metadata summary available.</p>";
}
function renderNftDetails(nft) {
if (!nft) {
resetNftDetails();
return;
}
const name = nftDisplayName(nft);
if (nftDetailTitle) {
nftDetailTitle.textContent = name;
}
if (nftDetailSubtitle) {
nftDetailSubtitle.textContent = nftTypeLabel(nft);
}
setNftDetailText(nftDetailName, name);
setNftDetailText(nftDetailAsset, nft.asset_name || `${nft.nft_name}_${nft.series}`);
setNftDetailText(nftDetailCreator, nft.creator ? shortenAddress(nft.creator) : "");
setNftDetailText(nftDetailGenesis, nft.genesis_txid ? shortenNftTx(nft.genesis_txid) : "");
setNftDetailText(nftDetailMetadata, nft.metadata_uri || nft.item_ipfs || "");
if (nftMetadataSummary) {
nftMetadataSummary.innerHTML = renderNftMetadataSummary(nft);
}
if (nftHistory) {
nftHistory.innerHTML = Array.isArray(nft.history) && nft.history.length
? nft.history.map(renderNftHistoryEntry).join("")
: '<div class="nft-history-empty">No provenance records returned.</div>';
}
}
function renderNftsPage() {
if (!nftsList) {
return;
}
nftsList.innerHTML = ownedNfts.map(renderNftCard).join("");
const selected = ownedNfts.find((nft) => nftKey(nft) === selectedNftKey) || ownedNfts[0];
selectedNftKey = selected ? nftKey(selected) : "";
renderNftDetails(selected);
if (!walletLoaded) {
setNftsState("Load a wallet to view NFTs.");
} else if (!ownedNftRows.length) {
setNftsState(nftsLoading ? "Loading owned NFTs..." : "No owned NFTs found for this address.");
} else if (!ownedNfts.length) {
const start = (nftsPage - 1) * NFTS_PAGE_SIZE + 1;
const end = Math.min(start + NFTS_PAGE_SIZE - 1, ownedNftRows.length);
const suffix = nftsCachingVisible ? " - Caching media..." : "";
setNftsState(`Loading ${start}-${end} of ${ownedNftRows.length} NFTs${suffix}`);
} else {
const start = (nftsPage - 1) * NFTS_PAGE_SIZE + 1;
const end = Math.min(start + ownedNfts.length - 1, ownedNftRows.length);
const suffix = nftsCachingVisible ? " - Caching media..." : "";
setNftsState(`Showing ${start}-${end} of ${ownedNftRows.length} NFTs${suffix}`);
}
renderNftsPagination();
}
async function loadNftMetadata(invoke, detail, gateway) {
const metadataUri = detail.metadata_uri || detail.item_ipfs || "";
if (!metadataUri) {
return null;
}
const cacheKey = `${gateway}|${metadataUri}`;
if (nftMetadataCache.has(cacheKey)) {
return nftMetadataCache.get(cacheKey);
}
const metadata = await invoke("fetch_ipfs_metadata", {
ipfsPath: metadataUri,
gateway
});
nftMetadataCache.set(cacheKey, metadata);
return metadata;
}
function nftFromCache(row, cached, gateway) {
const detail = cached?.detail && typeof cached.detail === "object" ? cached.detail : {};
const metadata = cached?.metadata && typeof cached.metadata === "object" ? cached.metadata : {};
return {
...row,
...detail,
metadata,
image_data_url: cached?.image_data_url || "",
gateway
};
}
function renderNftsPagination() {
if (!nftsPagination) {
return;
}
const totalPages = Math.max(1, Math.ceil(ownedNftRows.length / NFTS_PAGE_SIZE));
const showPagination = ownedNftRows.length > NFTS_PAGE_SIZE;
nftsPagination.hidden = !showPagination;
if (nftsPageTotal) {
nftsPageTotal.textContent = String(totalPages);
}
if (nftsPageInput && Number(nftsPageInput.value) !== nftsPage) {
nftsPageInput.value = String(nftsPage);
}
document.querySelectorAll("[data-nfts-page]").forEach((button) => {
const action = button.dataset.nftsPage;
button.disabled = !showPagination
|| (["first", "previous"].includes(action) && nftsPage <= 1)
|| (["next", "last"].includes(action) && nftsPage >= totalPages);
});
}
function clampNftsPage(page) {
const totalPages = Math.max(1, Math.ceil(ownedNftRows.length / NFTS_PAGE_SIZE));
return Math.min(Math.max(1, Number(page) || 1), totalPages);
}
function visibleNftRows() {
const start = (nftsPage - 1) * NFTS_PAGE_SIZE;
return ownedNftRows.slice(start, start + NFTS_PAGE_SIZE);
}
async function hydrateVisibleNftPage({ loadId, gateway, broadcastNode, force }) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || loadId !== nftsLoadId) {
return;
}
const rows = visibleNftRows();
const loaded = [];
nftsCachingVisible = false;
for (const row of rows) {
if (loadId !== nftsLoadId || currentView !== "nfts") {
return;
}
try {
if (!force) {
const cached = await invoke("read_nft_cache", {
nftName: row.nft_name,
series: row.series
});
if (cached) {
loaded.push(nftFromCache(row, cached, gateway));
ownedNfts = loaded.slice();
renderNftsPage();
continue;
}
}
nftsCachingVisible = true;
renderNftsPage();
const detail = await invoke("nft_details", {
broadcastNode,
nftName: row.nft_name,
series: row.series
});
let metadata = null;
try {
metadata = await loadNftMetadata(invoke, detail, gateway);
} catch (error) {
metadata = { description: `Metadata unavailable: ${String(error)}` };
}
const imageUrl = ipfsToGatewayUrl(
String(metadata?.image || metadata?.image_url || metadata?.animation_url || ""),
gateway
);
let cacheRecord = null;
try {
cacheRecord = await invoke("write_nft_cache", {
nftName: row.nft_name,
series: row.series,
detailJson: JSON.stringify(detail),
metadataJson: JSON.stringify(metadata || {}),
imageUrl
});
} catch (_) {
cacheRecord = null;
}
loaded.push(cacheRecord ? nftFromCache(row, cacheRecord, gateway) : {
...row,
...detail,
metadata,
gateway
});
ownedNfts = loaded.slice();
renderNftsPage();
} catch (error) {
loaded.push({
...row,
metadata: { description: `NFT details unavailable: ${String(error)}` },
gateway
});
ownedNfts = loaded.slice();
renderNftsPage();
}
}
nftsCachingVisible = false;
ownedNfts = loaded;
renderNftsPage();
}
async function prepareNftsPage({ force = false } = {}) {
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!walletLoaded) {
ownedNfts = [];
ownedNftRows = [];
renderNftsPage();
return;
}
if (!invoke || !broadcastNode) {
ownedNfts = [];
ownedNftRows = [];
setNftsState("NFT lookup skipped: wallet bridge or broadcast node is unavailable.");
renderNftsPage();
return;
}
if (nftsLoading && !force) {
return;
}
const loadId = ++nftsLoadId;
nftsLoading = true;
nftsCachingVisible = false;
renderNftsPage();
try {
const [balances, settings, nftRegistry] = await Promise.all([
invoke("get_active_wallet_balances", { broadcastNode }),
invoke("read_gui_settings"),
invoke("nft_list", { broadcastNode })
]);
if (loadId !== nftsLoadId || currentView !== "nfts") {
return;
}
const gateway = normalizeIpfsGateway(settings?.ipfs_gateway);
ownedNftRows = ownedNftRowsFromBalances(balances, nftRegistry);
if (!ownedNftRows.length && !Array.isArray(nftRegistry)) {
ownedNftRows = legacyOwnedNftRowsFromBalances(balances);
}
nftsPage = clampNftsPage(nftsPage);
invoke("prune_nft_cache", {
keepKeys: ownedNftRows.map(nftCacheKey)
}).catch(() => {});
ownedNfts = [];
renderNftsPage();
await hydrateVisibleNftPage({ loadId, gateway, broadcastNode, force });
} catch (error) {
ownedNfts = [];
ownedNftRows = [];
setNftsState(String(error));
} finally {
if (loadId === nftsLoadId) {
nftsLoading = false;
renderNftsPage();
}
}
}
function resetNftsPage() {
nftsLoadId += 1;
ownedNfts = [];
ownedNftRows = [];
selectedNftKey = "";
nftsPage = 1;
nftsLoading = false;
nftsCachingVisible = false;
resetNftDetails();
renderNftsPage();
}
nftsList?.addEventListener("click", (event) => {
const button = event.target.closest("[data-nft-select]");
const card = event.target.closest("[data-nft-key]");
const key = button?.dataset.nftSelect || card?.dataset.nftKey || "";
if (!key) {
return;
}
selectedNftKey = key;
renderNftsPage();
});
nftsRefreshButton?.addEventListener("click", () => {
nftMetadataCache.clear();
prepareNftsPage({ force: true });
});
function reloadNftPageFromControls(force = false) {
nftsLoadId += 1;
nftsLoading = false;
nftsCachingVisible = false;
ownedNfts = [];
selectedNftKey = "";
prepareNftsPage({ force });
}
document.querySelectorAll("[data-nfts-page]").forEach((button) => {
button.addEventListener("click", () => {
const totalPages = Math.max(1, Math.ceil(ownedNftRows.length / NFTS_PAGE_SIZE));
const action = button.dataset.nftsPage;
if (action === "first") {
nftsPage = 1;
} else if (action === "previous") {
nftsPage = clampNftsPage(nftsPage - 1);
} else if (action === "next") {
nftsPage = clampNftsPage(nftsPage + 1);
} else if (action === "last") {
nftsPage = totalPages;
}
reloadNftPageFromControls();
});
});
nftsPageInput?.addEventListener("change", () => {
nftsPage = clampNftsPage(nftsPageInput.value);
reloadNftPageFromControls();
});