598 lines
18 KiB
JavaScript
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("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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();
|
|
});
|