diff --git a/src/components/address/address-breadcrumb.tsx b/src/components/address/address-breadcrumb.tsx index b48778b..7728550 100644 --- a/src/components/address/address-breadcrumb.tsx +++ b/src/components/address/address-breadcrumb.tsx @@ -1,4 +1,5 @@ import { + AlertCircleIcon, ChevronRightIcon, FilterIcon, HomeIcon, @@ -25,6 +26,8 @@ const AddressBreadcrumb = ({ showAddAddress, setShowAddAddress, blocks, + loading, + hasError, }: { resolvedAddresses: ResolvedAddress[]; showFilters: boolean; @@ -32,6 +35,8 @@ const AddressBreadcrumb = ({ showAddAddress: boolean; setShowAddAddress: (show: boolean) => void; blocks: MinimalBlock[]; + loading: boolean; + hasError: boolean; }) => { const { theme } = useTheme(); @@ -55,7 +60,7 @@ const AddressBreadcrumb = ({ >
  1. -
    +
    + {/* Error indicator badge next to home icon */} + {hasError && ( + + + + + +

    Error loading address

    +
    +
    + )}
  2. @@ -76,12 +95,19 @@ const AddressBreadcrumb = ({ aria-current={"page"} className="ml-4 hidden text-sm font-medium text-gray-500 hover:text-gray-700 md:block dark:text-gray-400 dark:hover:text-gray-300" > - {resolvedAddresses.length === 0 && ( + {loading && resolvedAddresses.length === 0 && ( )} - {resolvedAddresses.length === 1 && + {!loading && hasError && resolvedAddresses.length === 0 && ( + + Error loading address + + )} + {!loading && + resolvedAddresses.length === 1 && getDisplayName(resolvedAddresses[0].address)} - {resolvedAddresses.length > 1 && + {!loading && + resolvedAddresses.length > 1 && `Multiple addresses (${resolvedAddresses.length})`} - {resolvedAddresses.length === 0 && ( + {loading && resolvedAddresses.length === 0 && ( )} - {resolvedAddresses.length === 1 && + {!loading && hasError && resolvedAddresses.length === 0 && ( + Error + )} + {!loading && + resolvedAddresses.length === 1 && getDisplayName(resolvedAddresses[0].address, true)} - {resolvedAddresses.length > 1 && "Multiple addresses"} + {!loading && resolvedAddresses.length > 1 && "Multiple addresses"}
    diff --git a/src/components/address/address-view.tsx b/src/components/address/address-view.tsx index 97d3405..976f8a7 100644 --- a/src/components/address/address-view.tsx +++ b/src/components/address/address-view.tsx @@ -116,7 +116,11 @@ export default function AddressView({ addresses }: { addresses: string }) { () => addresses.split(",").filter(Boolean), [addresses], ); - const { resolvedAddresses } = useAlgorandAddresses(addressesArray); + const { + resolvedAddresses, + loading: addressLoading, + hasError: addressError, + } = useAlgorandAddresses(addressesArray); // Function to update addresses in both state and URL const handleAddAddresses = (newAddresses: string[]) => { @@ -215,6 +219,8 @@ export default function AddressView({ addresses }: { addresses: string }) { showFilters={showFilters} setShowFilters={setShowFilters} blocks={filteredBlocks} + loading={addressLoading} + hasError={addressError} /> - {resolvedAddresses.length === 1 && ( + {resolvedAddresses.length === 1 && resolvedAddresses[0] && (

    - {displayAlgoAddress(resolvedAddresses[0].address)} + {displayAlgoAddress(resolvedAddresses[0]?.address)}

    - {resolvedAddresses[0].address} + {resolvedAddresses[0]?.address}

    diff --git a/src/hooks/useAlgorandAddress.ts b/src/hooks/useAlgorandAddress.ts index a02e81f..08a74ab 100644 --- a/src/hooks/useAlgorandAddress.ts +++ b/src/hooks/useAlgorandAddress.ts @@ -1,6 +1,7 @@ import { resolveNFD } from "@/hooks/queries/useNFD"; import * as React from "react"; import { ResolvedAddress } from "@/components/heatmap/types.ts"; +import { toast } from "sonner"; export const useAlgorandAddresses = (addresses: string[]) => { const [resolvedAddresses, setResolvedAddresses] = React.useState< @@ -16,13 +17,30 @@ export const useAlgorandAddresses = (addresses: string[]) => { const resolved = await Promise.all( addresses.map(async (address) => { if (address.toLowerCase().endsWith(".algo")) { - return { address: await resolveNFD(address), nfd: address }; + const resolvedAddr = await resolveNFD(address); + // If NFD resolution fails (expired, not found, etc.), skip it + if (!resolvedAddr || resolvedAddr.length !== 58) { + toast.error( + `NFD "${address}" could not be resolved. It may be expired or not found.`, + ); + return null; + } + return { address: resolvedAddr, nfd: address }; } return { address, nfd: null }; }), ); - setResolvedAddresses(resolved); + // Filter out any null values from failed NFD resolutions + const validAddresses = resolved.filter( + (addr): addr is ResolvedAddress => addr !== null, + ); + setResolvedAddresses(validAddresses); + + // If we had addresses but none resolved successfully, that's an error state + if (addresses.length > 0 && validAddresses.length === 0) { + setError(true); + } } catch (err) { console.error(err); setError(true); diff --git a/src/lib/block-storage.ts b/src/lib/block-storage.ts index b71505a..c0579c7 100644 --- a/src/lib/block-storage.ts +++ b/src/lib/block-storage.ts @@ -15,107 +15,160 @@ interface BlockCache { lastUpdated: number; } +let isIndexedDBAvailable: boolean | null = null; + +function checkIndexedDBAvailability(): boolean { + if (isIndexedDBAvailable !== null) { + return isIndexedDBAvailable; + } + + try { + // Check if indexedDB exists and is accessible + if (typeof indexedDB === "undefined" || indexedDB === null) { + isIndexedDBAvailable = false; + return false; + } + + // Try to access a property to ensure it's not a restricted context + // Some contexts throw on property access + const testAccess = indexedDB.open; + if (!testAccess) { + isIndexedDBAvailable = false; + return false; + } + + isIndexedDBAvailable = true; + return true; + } catch (error) { + console.warn("IndexedDB is not available in this context:", error); + isIndexedDBAvailable = false; + return false; + } +} + export async function initDB(): Promise { + // Check availability first without throwing + if (!checkIndexedDBAvailability()) { + return Promise.reject( + new Error("IndexedDB is not available in this context"), + ); + } + return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = () => { - reject( - new Error( - `Failed to open database: ${request.error}. Operation: initDB`, - ), - ); - }; - - request.onsuccess = () => { - resolve(request.result); - }; - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - // Create the store if it doesn't exist - if (!db.objectStoreNames.contains(BLOCKS_STORE)) { - const store = db.createObjectStore(BLOCKS_STORE, { - keyPath: "address", - }); - store.createIndex("address", "address", { unique: true }); - } - }; + try { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + reject( + new Error( + `Failed to open database: ${request.error}. Operation: initDB`, + ), + ); + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Create the store if it doesn't exist + if (!db.objectStoreNames.contains(BLOCKS_STORE)) { + const store = db.createObjectStore(BLOCKS_STORE, { + keyPath: "address", + }); + store.createIndex("address", "address", { unique: true }); + } + }; + } catch (error) { + reject(new Error(`IndexedDB access denied: ${error}`)); + } }); } export async function getBlocksFromCache( address: string, ): Promise { - const db = await initDB(); - - return new Promise((resolve, reject) => { - const transaction = db.transaction([BLOCKS_STORE], "readonly"); - const store = transaction.objectStore(BLOCKS_STORE); - const request = store.get(address); - - request.onerror = () => { - reject( - new Error( - `Failed to get blocks for ${address}: ${request.error}. Operation: getBlocksFromCache`, - ), - ); - }; - - request.onsuccess = () => { - const result = request.result as BlockCache | undefined; - - if (!result || !result.blocks) { - resolve(null); - return; - } - - const blocks = result.blocks.map(fromSerializableBlock); - resolve(blocks); - }; - - transaction.oncomplete = () => { - db.close(); - }; - }); + try { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([BLOCKS_STORE], "readonly"); + const store = transaction.objectStore(BLOCKS_STORE); + const request = store.get(address); + + request.onerror = () => { + reject( + new Error( + `Failed to get blocks for ${address}: ${request.error}. Operation: getBlocksFromCache`, + ), + ); + }; + + request.onsuccess = () => { + const result = request.result as BlockCache | undefined; + + if (!result || !result.blocks) { + resolve(null); + return; + } + + const blocks = result.blocks.map(fromSerializableBlock); + resolve(blocks); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.warn("Cache unavailable, returning null:", error); + return null; + } } export async function saveBlocksToCache( address: string, blocks: MinimalBlock[], ): Promise { - const db = await initDB(); - - return new Promise((resolve, reject) => { - const transaction = db.transaction([BLOCKS_STORE], "readwrite"); - const store = transaction.objectStore(BLOCKS_STORE); - const serializableBlocks = blocks.map(toSerializableBlock); - - const cache: BlockCache = { - address, - blocks: serializableBlocks, - lastUpdated: Date.now(), - }; - - const request = store.put(cache); - - request.onerror = () => { - reject( - new Error( - `Failed to save blocks for ${address}: ${request.error}. Operation: saveBlocksToCache`, - ), - ); - }; - - request.onsuccess = () => { - resolve(); - }; - - transaction.oncomplete = () => { - db.close(); - }; - }); + try { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([BLOCKS_STORE], "readwrite"); + const store = transaction.objectStore(BLOCKS_STORE); + const serializableBlocks = blocks.map(toSerializableBlock); + + const cache: BlockCache = { + address, + blocks: serializableBlocks, + lastUpdated: Date.now(), + }; + + const request = store.put(cache); + + request.onerror = () => { + reject( + new Error( + `Failed to save blocks for ${address}: ${request.error}. Operation: saveBlocksToCache`, + ), + ); + }; + + request.onsuccess = () => { + resolve(); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.warn("Cache unavailable, skipping save:", error); + // Gracefully continue without caching + return Promise.resolve(); + } } export async function getMaxRoundFromCache( @@ -131,51 +184,61 @@ export async function getMaxRoundFromCache( } export async function clearCacheForAddress(address: string): Promise { - const db = await initDB(); - - return new Promise((resolve, reject) => { - const transaction = db.transaction([BLOCKS_STORE], "readwrite"); - const store = transaction.objectStore(BLOCKS_STORE); - const request = store.delete(address); - - request.onerror = () => { - reject( - new Error( - `Failed to clear cache for ${address}: ${request.error}. Operation: clearCacheForAddress`, - ), - ); - }; - - request.onsuccess = () => { - resolve(); - }; - - transaction.oncomplete = () => { - db.close(); - }; - }); + try { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([BLOCKS_STORE], "readwrite"); + const store = transaction.objectStore(BLOCKS_STORE); + const request = store.delete(address); + + request.onerror = () => { + reject( + new Error( + `Failed to clear cache for ${address}: ${request.error}. Operation: clearCacheForAddress`, + ), + ); + }; + + request.onsuccess = () => { + resolve(); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.warn("Cache unavailable, skipping clear:", error); + return Promise.resolve(); + } } export async function clearAllCache(): Promise { - const db = await initDB(); - - return new Promise((resolve, reject) => { - const transaction = db.transaction([BLOCKS_STORE], "readwrite"); - const store = transaction.objectStore(BLOCKS_STORE); - const request = store.clear(); - - request.onerror = () => { - reject(new Error(`Failed to clear all cache: ${request.error}`)); - }; - - request.onsuccess = () => { - resolve(); - }; - - transaction.oncomplete = () => { - db.close(); - }; - }); + try { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([BLOCKS_STORE], "readwrite"); + const store = transaction.objectStore(BLOCKS_STORE); + const request = store.clear(); + + request.onerror = () => { + reject(new Error(`Failed to clear all cache: ${request.error}`)); + }; + + request.onsuccess = () => { + resolve(); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.warn("Cache unavailable, skipping clear:", error); + return Promise.resolve(); + } } export async function getAllCachedAddresses(): Promise< @@ -186,94 +249,111 @@ export async function getAllCachedAddresses(): Promise< sizeInBytes: number; }> > { - const db = await initDB(); - - return new Promise((resolve, reject) => { - const transaction = db.transaction([BLOCKS_STORE], "readonly"); - const store = transaction.objectStore(BLOCKS_STORE); - const request = store.getAll(); - - request.onsuccess = () => { - const allCaches: BlockCache[] = request.result; - const results = allCaches.map((cache) => { - const sizeInBytes = new Blob([JSON.stringify(cache)]).size; - - return { - address: cache.address, - blockCount: cache.blocks.length, - lastUpdated: cache.lastUpdated, - sizeInBytes, - }; - }); - resolve(results); - }; - - request.onerror = () => { - reject(new Error(`Failed to get all cached addresses: ${request.error}`)); - }; - - transaction.oncomplete = () => { - db.close(); - }; - }); + try { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([BLOCKS_STORE], "readonly"); + const store = transaction.objectStore(BLOCKS_STORE); + const request = store.getAll(); + + request.onsuccess = () => { + const allCaches: BlockCache[] = request.result; + const results = allCaches.map((cache) => { + const sizeInBytes = new Blob([JSON.stringify(cache)]).size; + + return { + address: cache.address, + blockCount: cache.blocks.length, + lastUpdated: cache.lastUpdated, + sizeInBytes, + }; + }); + resolve(results); + }; + + request.onerror = () => { + reject( + new Error(`Failed to get all cached addresses: ${request.error}`), + ); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.warn("Cache unavailable, returning empty array:", error); + return []; + } } export async function getCachedAddresses(): Promise { - const db = await initDB(); - - return new Promise((resolve, reject) => { - const transaction = db.transaction([BLOCKS_STORE], "readonly"); - const store = transaction.objectStore(BLOCKS_STORE); - const request = store.getAllKeys(); - - request.onerror = () => { - reject(new Error(`Failed to get cached addresses: ${request.error}`)); - }; - - request.onsuccess = () => { - resolve(request.result as string[]); - }; - - transaction.oncomplete = () => { - db.close(); - }; - }); + try { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([BLOCKS_STORE], "readonly"); + const store = transaction.objectStore(BLOCKS_STORE); + const request = store.getAllKeys(); + + request.onerror = () => { + reject(new Error(`Failed to get cached addresses: ${request.error}`)); + }; + + request.onsuccess = () => { + resolve(request.result as string[]); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.warn("Cache unavailable, returning empty array:", error); + return []; + } } export async function getCacheMetadata( address: string, ): Promise<{ lastUpdated: number; blockCount: number } | null> { - const db = await initDB(); - - return new Promise((resolve, reject) => { - const transaction = db.transaction([BLOCKS_STORE], "readonly"); - const store = transaction.objectStore(BLOCKS_STORE); - const request = store.get(address); - - request.onerror = () => { - reject( - new Error( - `Failed to get cache metadata for ${address}: ${request.error}`, - ), - ); - }; - - request.onsuccess = () => { - const result = request.result as BlockCache | undefined; - - if (!result) { - resolve(null); - return; - } - - resolve({ - lastUpdated: result.lastUpdated, - blockCount: result.blocks.length, - }); - }; - - transaction.oncomplete = () => { - db.close(); - }; - }); + try { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([BLOCKS_STORE], "readonly"); + const store = transaction.objectStore(BLOCKS_STORE); + const request = store.get(address); + + request.onerror = () => { + reject( + new Error( + `Failed to get cache metadata for ${address}: ${request.error}`, + ), + ); + }; + + request.onsuccess = () => { + const result = request.result as BlockCache | undefined; + + if (!result) { + resolve(null); + return; + } + + resolve({ + lastUpdated: result.lastUpdated, + blockCount: result.blocks.length, + }); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.warn("Cache unavailable, returning null:", error); + return null; + } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a288576..2965070 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,7 +5,13 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export function displayAlgoAddress(address: string, lettersToDisplay = 5) { +export function displayAlgoAddress( + address: string | undefined, + lettersToDisplay = 5, +) { + if (!address || address.length < lettersToDisplay * 2) { + return address || ""; + } return `${address.slice(0, lettersToDisplay)}...${address.slice( -lettersToDisplay, )}`;