Frontend: complete task list (C1–L4), security, a11y, L1 block card helper

- React: response.ok checks (address, transaction, search); block number validation; stable Table keys; API modules (addresses, transactions, blocks normalizer)
- SPA: escapeHtml/safe URLs/onclick; getRpcUrl in rpcCall; cancel blocks rAF on view change; named constants; hash route decode
- SPA: createBlockCardHtml + normalizeBlockDisplay (L1); DEBUG console gating; aria-live for errors; token/block/tx detail escaping
- Docs: FRONTEND_REVIEW.md, FRONTEND_TASKS_AND_REVIEW.md; favicons; .gitignore *.tsbuildinfo

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-02-10 18:43:37 -08:00
parent 1c8ca4172a
commit 2b956a5a83
16 changed files with 847 additions and 315 deletions

View File

@@ -11,9 +11,11 @@ interface TableProps<T> {
columns: Column<T>[]
data: T[]
className?: string
/** Stable key for each row (e.g. row => row.id or row => row.hash). Falls back to index if not provided. */
keyExtractor?: (row: T) => string | number
}
export function Table<T>({ columns, data, className }: TableProps<T>) {
export function Table<T>({ columns, data, className, keyExtractor }: TableProps<T>) {
return (
<div className={clsx('overflow-x-auto', className)}>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
@@ -34,7 +36,7 @@ export function Table<T>({ columns, data, className }: TableProps<T>) {
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{data.map((row, rowIndex) => (
<tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<tr key={keyExtractor ? keyExtractor(row) : rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800">
{columns.map((column, colIndex) => (
<td
key={colIndex}

View File

@@ -5,25 +5,7 @@ import { useParams } from 'next/navigation'
import { Card } from '@/components/common/Card'
import { Address } from '@/components/blockchain/Address'
import { Table } from '@/components/common/Table'
interface AddressInfo {
address: string
chain_id: number
transaction_count: number
token_count: number
is_contract: boolean
label?: string
tags: string[]
}
interface Transaction {
hash: string
block_number: number
from_address: string
to_address?: string
value: string
status?: number
}
import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses'
export default function AddressDetailPage() {
const params = useParams()
@@ -31,7 +13,7 @@ export default function AddressDetailPage() {
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
const [transactions, setTransactions] = useState<Transaction[]>([])
const [transactions, setTransactions] = useState<TransactionSummary[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -41,25 +23,21 @@ export default function AddressDetailPage() {
const loadAddressInfo = async () => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/addresses/${chainId}/${address}`
)
const data = await response.json()
setAddressInfo(data.data)
const response = await addressesApi.get(chainId, address)
setAddressInfo(response.data ?? null)
} catch (error) {
console.error('Failed to load address info:', error)
setAddressInfo(null)
}
}
const loadTransactions = async () => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/transactions?chain_id=${chainId}&from_address=${address}&page=1&page_size=20`
)
const data = await response.json()
setTransactions(data.data || [])
const response = await addressesApi.getTransactions(chainId, address, 1, 20)
setTransactions(response.data || [])
} catch (error) {
console.error('Failed to load transactions:', error)
setTransactions([])
} finally {
setLoading(false)
}
@@ -76,7 +54,7 @@ export default function AddressDetailPage() {
const transactionColumns = [
{
header: 'Hash',
accessor: (tx: Transaction) => (
accessor: (tx: TransactionSummary) => (
<a href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
<Address address={tx.hash} truncate />
</a>
@@ -84,15 +62,15 @@ export default function AddressDetailPage() {
},
{
header: 'Block',
accessor: (tx: Transaction) => tx.block_number,
accessor: (tx: TransactionSummary) => tx.block_number,
},
{
header: 'To',
accessor: (tx: Transaction) => tx.to_address ? <Address address={tx.to_address} truncate /> : 'Contract Creation',
accessor: (tx: TransactionSummary) => tx.to_address ? <Address address={tx.to_address} truncate /> : 'Contract Creation',
},
{
header: 'Value',
accessor: (tx: Transaction) => {
accessor: (tx: TransactionSummary) => {
const value = BigInt(tx.value)
const eth = Number(value) / 1e18
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
@@ -100,7 +78,7 @@ export default function AddressDetailPage() {
},
{
header: 'Status',
accessor: (tx: Transaction) => (
accessor: (tx: TransactionSummary) => (
<span className={tx.status === 1 ? 'text-green-600' : 'text-red-600'}>
{tx.status === 1 ? 'Success' : 'Failed'}
</span>
@@ -148,7 +126,7 @@ export default function AddressDetailPage() {
</Card>
<Card title="Transactions">
<Table columns={transactionColumns} data={transactions} />
<Table columns={transactionColumns} data={transactions} keyExtractor={(tx) => tx.hash} />
</Card>
</div>
)

View File

@@ -9,15 +9,22 @@ import Link from 'next/link'
export default function BlockDetailPage() {
const params = useParams()
const blockNumber = parseInt((params?.number as string) ?? '0')
const rawNumber = (params?.number as string) ?? ''
const blockNumber = parseInt(rawNumber, 10)
const isValidBlock = rawNumber !== '' && !Number.isNaN(blockNumber) && blockNumber >= 0
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [block, setBlock] = useState<Block | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!isValidBlock) {
setLoading(false)
setBlock(null)
return
}
loadBlock()
}, [blockNumber])
}, [blockNumber, isValidBlock])
const loadBlock = async () => {
setLoading(true)
@@ -31,6 +38,10 @@ export default function BlockDetailPage() {
}
}
if (!isValidBlock) {
return <div className="p-8">Invalid block number. Please use a valid block number from the URL.</div>
}
if (loading) {
return <div className="p-8">Loading block...</div>
}

View File

@@ -32,9 +32,14 @@ export default function SearchPage() {
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/search?q=${encodeURIComponent(query)}`
)
const data = await response.json()
if (!response.ok) {
setResults([])
return
}
setResults(data.results || [])
} catch (error) {
console.error('Search failed:', error)
setResults([])
} finally {
setLoading(false)
}
@@ -67,7 +72,7 @@ export default function SearchPage() {
<Card title="Search Results">
<div className="space-y-4">
{results.map((result, index) => (
<div key={index} className="border-b border-gray-200 dark:border-gray-700 pb-4 last:border-0">
<div key={result.data?.hash ?? result.data?.address ?? result.data?.number ?? index} className="border-b border-gray-200 dark:border-gray-700 pb-4 last:border-0">
{result.type === 'block' && result.data.number && (
<Link href={`/blocks/${result.data.number}`} className="text-primary-600 hover:underline">
Block #{result.data.number}

View File

@@ -5,26 +5,7 @@ import { useParams } from 'next/navigation'
import { Card } from '@/components/common/Card'
import { Address } from '@/components/blockchain/Address'
import Link from 'next/link'
interface Transaction {
chain_id: number
hash: string
block_number: number
block_hash: string
transaction_index: number
from_address: string
to_address?: string
value: string
gas_price?: number
max_fee_per_gas?: number
max_priority_fee_per_gas?: number
gas_limit: number
gas_used?: number
status?: number
input_data?: string
contract_address?: string
created_at: string
}
import { transactionsApi, Transaction } from '@/services/api/transactions'
export default function TransactionDetailPage() {
const params = useParams()
@@ -41,13 +22,11 @@ export default function TransactionDetailPage() {
const loadTransaction = async () => {
setLoading(true)
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/transactions/${chainId}/${hash}`
)
const data = await response.json()
setTransaction(data.data)
const response = await transactionsApi.get(chainId, hash)
setTransaction(response.data ?? null)
} catch (error) {
console.error('Failed to load transaction:', error)
setTransaction(null)
} finally {
setLoading(false)
}

View File

@@ -0,0 +1,53 @@
import { apiClient, ApiResponse } from './client'
export interface AddressInfo {
address: string
chain_id: number
transaction_count: number
token_count: number
is_contract: boolean
label?: string
tags: string[]
}
export interface AddressTransactionsParams {
chain_id: number
from_address: string
page?: number
page_size?: number
}
export interface TransactionSummary {
hash: string
block_number: number
from_address: string
to_address?: string
value: string
status?: number
}
export const addressesApi = {
get: async (chainId: number, address: string): Promise<ApiResponse<AddressInfo>> => {
return apiClient.get<AddressInfo>(`/api/v1/addresses/${chainId}/${address}`)
},
getTransactions: async (
chainId: number,
address: string,
page = 1,
pageSize = 20
): Promise<ApiResponse<TransactionSummary[]>> => {
const params = new URLSearchParams({
chain_id: chainId.toString(),
from_address: address,
page: page.toString(),
page_size: pageSize.toString(),
})
const raw = (await apiClient.get(`/api/v1/transactions?${params.toString()}`)) as unknown as {
data?: TransactionSummary[]
items?: TransactionSummary[]
}
const data = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.items) ? raw.items : []
return { data }
},
}

View File

@@ -22,6 +22,12 @@ export interface BlockListParams {
order?: 'asc' | 'desc'
}
/** Normalize list response: backend may return { data: T[] } or { items: T[] }. */
function normalizeListResponse<T>(raw: { data?: T[]; items?: T[] }): ApiResponse<T[]> {
const data = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.items) ? raw.items : []
return { data }
}
export const blocksApi = {
list: async (params: BlockListParams): Promise<ApiResponse<Block[]>> => {
const queryParams = new URLSearchParams()
@@ -34,7 +40,8 @@ export const blocksApi = {
if (params.sort) queryParams.append('sort', params.sort)
if (params.order) queryParams.append('order', params.order)
return apiClient.get<Block[]>(`/api/v1/blocks?${queryParams.toString()}`)
const raw = (await apiClient.get(`/api/v1/blocks?${queryParams.toString()}`)) as unknown as { data?: Block[]; items?: Block[] }
return normalizeListResponse(raw)
},
getByNumber: async (chainId: number, number: number): Promise<ApiResponse<Block>> => {

View File

@@ -0,0 +1,27 @@
import { apiClient, ApiResponse } from './client'
export interface Transaction {
chain_id: number
hash: string
block_number: number
block_hash: string
transaction_index: number
from_address: string
to_address?: string
value: string
gas_price?: number
max_fee_per_gas?: number
max_priority_fee_per_gas?: number
gas_limit: number
gas_used?: number
status?: number
input_data?: string
contract_address?: string
created_at: string
}
export const transactionsApi = {
get: async (chainId: number, hash: string): Promise<ApiResponse<Transaction>> => {
return apiClient.get<Transaction>(`/api/v1/transactions/${chainId}/${hash}`)
},
}