Add full monorepo: virtual-banker, backend, frontend, docs, scripts, deployment
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
28
frontend/src/app/globals.css
Normal file
28
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,28 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
53
frontend/src/app/layout.tsx
Normal file
53
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import Link from 'next/link'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'SolaceScanScout | The Defi Oracle Meta Explorer',
|
||||
description: 'The Defi Oracle Meta Explorer - Comprehensive blockchain explorer for ChainID 138',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<nav className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/" className="text-xl font-bold text-primary-600">
|
||||
<div className="flex flex-col">
|
||||
<span>SolaceScanScout</span>
|
||||
<span className="text-xs font-normal text-gray-500">The Defi Oracle Meta Explorer</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
<Link href="/blocks" className="text-gray-700 dark:text-gray-300 hover:text-primary-600">
|
||||
Blocks
|
||||
</Link>
|
||||
<Link href="/transactions" className="text-gray-700 dark:text-gray-300 hover:text-primary-600">
|
||||
Transactions
|
||||
</Link>
|
||||
<Link href="/search" className="text-gray-700 dark:text-gray-300 hover:text-primary-600">
|
||||
Search
|
||||
</Link>
|
||||
<Link href="/wallet" className="text-gray-700 dark:text-gray-300 hover:text-primary-600">
|
||||
Wallet
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
104
frontend/src/app/page.tsx
Normal file
104
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card } from '@/components/common/Card'
|
||||
import Link from 'next/link'
|
||||
import { blocksApi } from '@/services/api/blocks'
|
||||
|
||||
interface NetworkStats {
|
||||
current_block: number
|
||||
tps: number
|
||||
gps: number
|
||||
avg_gas_price: number
|
||||
pending_transactions: number
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState<NetworkStats | null>(null)
|
||||
const [recentBlocks, setRecentBlocks] = useState<any[]>([])
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
loadRecentBlocks()
|
||||
}, [])
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
// This would call analytics API
|
||||
// For now, placeholder
|
||||
setStats({
|
||||
current_block: 0,
|
||||
tps: 0,
|
||||
gps: 0,
|
||||
avg_gas_price: 0,
|
||||
pending_transactions: 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecentBlocks = async () => {
|
||||
try {
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
})
|
||||
setRecentBlocks(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent blocks:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2">SolaceScanScout</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">The Defi Oracle Meta Explorer</p>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Current Block</div>
|
||||
<div className="text-2xl font-bold">{stats.current_block.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">TPS</div>
|
||||
<div className="text-2xl font-bold">{stats.tps.toFixed(2)}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Gas Price</div>
|
||||
<div className="text-2xl font-bold">{stats.avg_gas_price.toLocaleString()} Gwei</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Pending Tx</div>
|
||||
<div className="text-2xl font-bold">{stats.pending_transactions}</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="Recent Blocks">
|
||||
<div className="space-y-2">
|
||||
{recentBlocks.map((block) => (
|
||||
<div key={block.number} className="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Link href="/blocks" className="text-primary-600 hover:underline">
|
||||
View all blocks →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
13
frontend/src/app/wallet/page.tsx
Normal file
13
frontend/src/app/wallet/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
|
||||
|
||||
export default function WalletPage() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Wallet & MetaMask</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets. Use the token list URL so tokens and oracles are discoverable.
|
||||
</p>
|
||||
<AddToMetaMask />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
48
frontend/src/components/blockchain/Address.tsx
Normal file
48
frontend/src/components/blockchain/Address.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface AddressProps {
|
||||
address: string
|
||||
chainId?: number
|
||||
showCopy?: boolean
|
||||
showENS?: boolean
|
||||
truncate?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Address({
|
||||
address,
|
||||
chainId,
|
||||
showCopy = true,
|
||||
showENS = false,
|
||||
truncate = false,
|
||||
className,
|
||||
}: AddressProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const displayAddress = truncate
|
||||
? `${address.slice(0, 6)}...${address.slice(-4)}`
|
||||
: address
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(address)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-2', className)}>
|
||||
<span className="font-mono text-sm">{displayAddress}</span>
|
||||
{showCopy && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
title="Copy address"
|
||||
>
|
||||
{copied ? '✓' : '📋'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
37
frontend/src/components/common/Button.tsx
Normal file
37
frontend/src/components/common/Button.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'font-medium rounded-lg transition-colors',
|
||||
{
|
||||
'bg-primary-600 text-white hover:bg-primary-700': variant === 'primary',
|
||||
'bg-gray-200 text-gray-900 hover:bg-gray-300': variant === 'secondary',
|
||||
'bg-red-600 text-white hover:bg-red-700': variant === 'danger',
|
||||
'px-3 py-1.5 text-sm': size === 'sm',
|
||||
'px-4 py-2 text-base': size === 'md',
|
||||
'px-6 py-3 text-lg': size === 'lg',
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
27
frontend/src/components/common/Card.tsx
Normal file
27
frontend/src/components/common/Card.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function Card({ children, className, title }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white dark:bg-gray-800 rounded-lg shadow-md p-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
56
frontend/src/components/common/Table.tsx
Normal file
56
frontend/src/components/common/Table.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Column<T> {
|
||||
header: string
|
||||
accessor: (row: T) => ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Table<T>({ columns, data, className }: TableProps<T>) {
|
||||
return (
|
||||
<div className={clsx('overflow-x-auto', className)}>
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className={clsx(
|
||||
'px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider',
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</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">
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className={clsx(
|
||||
'px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100',
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.accessor(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
102
frontend/src/components/wallet/AddToMetaMask.tsx
Normal file
102
frontend/src/components/wallet/AddToMetaMask.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
const CHAIN_138 = {
|
||||
chainId: '0x8a',
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org'],
|
||||
}
|
||||
|
||||
const CHAIN_MAINNET = {
|
||||
chainId: '0x1',
|
||||
chainName: 'Ethereum Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://eth.llamarpc.com', 'https://rpc.ankr.com/eth', 'https://ethereum.publicnode.com'],
|
||||
blockExplorerUrls: ['https://etherscan.io'],
|
||||
}
|
||||
|
||||
const CHAIN_ALL_MAINNET = {
|
||||
chainId: '0x9f2c4',
|
||||
chainName: 'ALL Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://mainnet-rpc.alltra.global'],
|
||||
blockExplorerUrls: ['https://alltra.global'],
|
||||
}
|
||||
|
||||
export function AddToMetaMask() {
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const ethereum = typeof window !== 'undefined' ? (window as unknown as { ethereum?: { request: (args: { method: string; params: unknown[] }) => Promise<unknown> } }).ethereum : undefined
|
||||
|
||||
const addChain = async (chain: typeof CHAIN_138) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [chain],
|
||||
})
|
||||
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
|
||||
} catch (e) {
|
||||
const err = e as { code?: number; message?: string }
|
||||
if (err.code === 4902) {
|
||||
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
|
||||
} else {
|
||||
setError(err.message || 'Failed to add network')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Production (explorer.d-bis.org): same origin; dev: NEXT_PUBLIC_API_URL or localhost
|
||||
const apiBase =
|
||||
typeof window !== 'undefined'
|
||||
? process.env.NEXT_PUBLIC_API_URL || window.location.origin
|
||||
: process.env.NEXT_PUBLIC_API_URL || 'https://explorer.d-bis.org'
|
||||
const tokenListUrl = apiBase.replace(/\/$/, '') + '/api/config/token-list'
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 space-y-4">
|
||||
<h2 className="text-lg font-semibold">Add to MetaMask</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Add Chain 138 (DeFi Oracle Meta Mainnet), Ethereum Mainnet, or ALL Mainnet to your wallet. Then add the token list URL in MetaMask Settings so tokens appear automatically.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(CHAIN_138)}
|
||||
className="px-4 py-2 rounded bg-primary-600 text-white hover:bg-primary-700 text-sm font-medium"
|
||||
>
|
||||
Add Chain 138
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(CHAIN_MAINNET)}
|
||||
className="px-4 py-2 rounded bg-gray-600 text-white hover:bg-gray-700 text-sm font-medium"
|
||||
>
|
||||
Add Ethereum Mainnet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(CHAIN_ALL_MAINNET)}
|
||||
className="px-4 py-2 rounded bg-gray-600 text-white hover:bg-gray-700 text-sm font-medium"
|
||||
>
|
||||
Add ALL Mainnet
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-1">Token list URL (add in MetaMask Settings → Token lists):</p>
|
||||
<code className="block p-2 rounded bg-gray-100 dark:bg-gray-900 break-all text-xs">{tokenListUrl}</code>
|
||||
</div>
|
||||
{status && <p className="text-sm text-green-600 dark:text-green-400">{status}</p>}
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
frontend/src/pages/_app.tsx
Normal file
7
frontend/src/pages/_app.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { AppProps } from 'next/app'
|
||||
import '../app/globals.css'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
||||
|
||||
156
frontend/src/pages/addresses/[address].tsx
Normal file
156
frontend/src/pages/addresses/[address].tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
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
|
||||
}
|
||||
|
||||
export default function AddressDetailPage() {
|
||||
const params = useParams()
|
||||
const address = (params?.address as string) ?? ''
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadAddressInfo()
|
||||
loadTransactions()
|
||||
}, [address])
|
||||
|
||||
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)
|
||||
} catch (error) {
|
||||
console.error('Failed to load address info:', error)
|
||||
}
|
||||
}
|
||||
|
||||
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 || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading address...</div>
|
||||
}
|
||||
|
||||
if (!addressInfo) {
|
||||
return <div className="p-8">Address not found</div>
|
||||
}
|
||||
|
||||
const transactionColumns = [
|
||||
{
|
||||
header: 'Hash',
|
||||
accessor: (tx: Transaction) => (
|
||||
<a href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.hash} truncate />
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Block',
|
||||
accessor: (tx: Transaction) => tx.block_number,
|
||||
},
|
||||
{
|
||||
header: 'To',
|
||||
accessor: (tx: Transaction) => tx.to_address ? <Address address={tx.to_address} truncate /> : 'Contract Creation',
|
||||
},
|
||||
{
|
||||
header: 'Value',
|
||||
accessor: (tx: Transaction) => {
|
||||
const value = BigInt(tx.value)
|
||||
const eth = Number(value) / 1e18
|
||||
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessor: (tx: Transaction) => (
|
||||
<span className={tx.status === 1 ? 'text-green-600' : 'text-red-600'}>
|
||||
{tx.status === 1 ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">
|
||||
{addressInfo.label || 'Address'}
|
||||
</h1>
|
||||
|
||||
<Card title="Address Information" className="mb-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Address:</span>
|
||||
<Address address={addressInfo.address} className="ml-2" />
|
||||
</div>
|
||||
{addressInfo.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold">Tags:</span>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{addressInfo.tags.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Transactions:</span>
|
||||
<span className="ml-2">{addressInfo.transaction_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Tokens:</span>
|
||||
<span className="ml-2">{addressInfo.token_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Type:</span>
|
||||
<span className="ml-2">{addressInfo.is_contract ? 'Contract' : 'EOA'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Transactions">
|
||||
<Table columns={transactionColumns} data={transactions} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
73
frontend/src/pages/blocks/[number].tsx
Normal file
73
frontend/src/pages/blocks/[number].tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { blocksApi, Block } from '@/services/api/blocks'
|
||||
import { Card } from '@/components/common/Card'
|
||||
import { Address } from '@/components/blockchain/Address'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function BlockDetailPage() {
|
||||
const params = useParams()
|
||||
const blockNumber = parseInt((params?.number as string) ?? '0')
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
const [block, setBlock] = useState<Block | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadBlock()
|
||||
}, [blockNumber])
|
||||
|
||||
const loadBlock = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await blocksApi.getByNumber(chainId, blockNumber)
|
||||
setBlock(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load block:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading block...</div>
|
||||
}
|
||||
|
||||
if (!block) {
|
||||
return <div className="p-8">Block not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Block #{block.number}</h1>
|
||||
|
||||
<Card title="Block Information">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Hash:</span>
|
||||
<Address address={block.hash} className="ml-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Timestamp:</span>
|
||||
<span className="ml-2">{new Date(block.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Miner:</span>
|
||||
<Address address={block.miner} className="ml-2" truncate />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Transactions:</span>
|
||||
<span className="ml-2">{block.transaction_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Gas Used:</span>
|
||||
<span className="ml-2">{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
92
frontend/src/pages/blocks/index.tsx
Normal file
92
frontend/src/pages/blocks/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { blocksApi, Block } from '@/services/api/blocks'
|
||||
import { Card } from '@/components/common/Card'
|
||||
import { Address } from '@/components/blockchain/Address'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function BlocksPage() {
|
||||
const [blocks, setBlocks] = useState<Block[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
useEffect(() => {
|
||||
loadBlocks()
|
||||
}, [page])
|
||||
|
||||
const loadBlocks = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page,
|
||||
page_size: 20,
|
||||
sort: 'number',
|
||||
order: 'desc',
|
||||
})
|
||||
setBlocks(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load blocks:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading blocks...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Blocks</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
{blocks.map((block) => (
|
||||
<Card key={block.number}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Link
|
||||
href={`/blocks/${block.number}`}
|
||||
className="text-lg font-semibold text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
<Address address={block.hash} truncate />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm">
|
||||
{new Date(block.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-4 py-2">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-4 py-2 bg-gray-200 rounded"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
97
frontend/src/pages/search/index.tsx
Normal file
97
frontend/src/pages/search/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card } from '@/components/common/Card'
|
||||
import { Address } from '@/components/blockchain/Address'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface SearchResult {
|
||||
type: string
|
||||
chain_id: number
|
||||
data: {
|
||||
hash?: string
|
||||
address?: string
|
||||
number?: number
|
||||
block_number?: number
|
||||
}
|
||||
score: number
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!query.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/search?q=${encodeURIComponent(query)}`
|
||||
)
|
||||
const data = await response.json()
|
||||
setResults(data.results || [])
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Search</h1>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by address, transaction hash, block number..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{results.length > 0 && (
|
||||
<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">
|
||||
{result.type === 'block' && result.data.number && (
|
||||
<Link href={`/blocks/${result.data.number}`} className="text-primary-600 hover:underline">
|
||||
Block #{result.data.number}
|
||||
</Link>
|
||||
)}
|
||||
{result.type === 'transaction' && result.data.hash && (
|
||||
<Link href={`/transactions/${result.data.hash}`} className="text-primary-600 hover:underline">
|
||||
Transaction <Address address={result.data.hash} truncate />
|
||||
</Link>
|
||||
)}
|
||||
{result.type === 'address' && result.data.address && (
|
||||
<Link href={`/addresses/${result.data.address}`} className="text-primary-600 hover:underline">
|
||||
Address <Address address={result.data.address} truncate />
|
||||
</Link>
|
||||
)}
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
Type: {result.type} | Chain: {result.chain_id} | Score: {result.score.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
132
frontend/src/pages/transactions/[hash].tsx
Normal file
132
frontend/src/pages/transactions/[hash].tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
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
|
||||
}
|
||||
|
||||
export default function TransactionDetailPage() {
|
||||
const params = useParams()
|
||||
const hash = (params?.hash as string) ?? ''
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
const [transaction, setTransaction] = useState<Transaction | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadTransaction()
|
||||
}, [hash])
|
||||
|
||||
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)
|
||||
} catch (error) {
|
||||
console.error('Failed to load transaction:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading transaction...</div>
|
||||
}
|
||||
|
||||
if (!transaction) {
|
||||
return <div className="p-8">Transaction not found</div>
|
||||
}
|
||||
|
||||
const value = BigInt(transaction.value)
|
||||
const ethValue = Number(value) / 1e18
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Transaction</h1>
|
||||
|
||||
<Card title="Transaction Information">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Hash:</span>
|
||||
<Address address={transaction.hash} className="ml-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Block:</span>
|
||||
<Link href={`/blocks/${transaction.block_number}`} className="ml-2 text-primary-600 hover:underline">
|
||||
#{transaction.block_number}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">From:</span>
|
||||
<Link href={`/addresses/${transaction.from_address}`} className="ml-2">
|
||||
<Address address={transaction.from_address} truncate />
|
||||
</Link>
|
||||
</div>
|
||||
{transaction.to_address && (
|
||||
<div>
|
||||
<span className="font-semibold">To:</span>
|
||||
<Link href={`/addresses/${transaction.to_address}`} className="ml-2">
|
||||
<Address address={transaction.to_address} truncate />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Value:</span>
|
||||
<span className="ml-2">{ethValue.toFixed(4)} ETH</span>
|
||||
</div>
|
||||
{transaction.gas_price && (
|
||||
<div>
|
||||
<span className="font-semibold">Gas Price:</span>
|
||||
<span className="ml-2">{transaction.gas_price / 1e9} Gwei</span>
|
||||
</div>
|
||||
)}
|
||||
{transaction.gas_used && (
|
||||
<div>
|
||||
<span className="font-semibold">Gas Used:</span>
|
||||
<span className="ml-2">{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Status:</span>
|
||||
<span className={`ml-2 ${transaction.status === 1 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{transaction.status === 1 ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
{transaction.contract_address && (
|
||||
<div>
|
||||
<span className="font-semibold">Contract Created:</span>
|
||||
<Link href={`/addresses/${transaction.contract_address}`} className="ml-2">
|
||||
<Address address={transaction.contract_address} truncate />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
119
frontend/src/pages/transactions/index.tsx
Normal file
119
frontend/src/pages/transactions/index.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table } from '@/components/common/Table'
|
||||
import { Address } from '@/components/blockchain/Address'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Transaction {
|
||||
chain_id: number
|
||||
hash: string
|
||||
block_number: number
|
||||
transaction_index: number
|
||||
from_address: string
|
||||
to_address?: string
|
||||
value: string
|
||||
gas_price?: number
|
||||
gas_used?: number
|
||||
status?: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
useEffect(() => {
|
||||
loadTransactions()
|
||||
}, [page])
|
||||
|
||||
const loadTransactions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/transactions?chain_id=${chainId}&page=${page}&page_size=20`
|
||||
)
|
||||
const data = await response.json()
|
||||
setTransactions(data.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Hash',
|
||||
accessor: (tx: Transaction) => (
|
||||
<Link href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.hash} truncate />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Block',
|
||||
accessor: (tx: Transaction) => (
|
||||
<Link href={`/blocks/${tx.block_number}`} className="text-primary-600 hover:underline">
|
||||
{tx.block_number}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'From',
|
||||
accessor: (tx: Transaction) => <Address address={tx.from_address} truncate />,
|
||||
},
|
||||
{
|
||||
header: 'To',
|
||||
accessor: (tx: Transaction) => tx.to_address ? <Address address={tx.to_address} truncate /> : <span className="text-gray-400">Contract Creation</span>,
|
||||
},
|
||||
{
|
||||
header: 'Value',
|
||||
accessor: (tx: Transaction) => {
|
||||
const value = BigInt(tx.value)
|
||||
const eth = Number(value) / 1e18
|
||||
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessor: (tx: Transaction) => (
|
||||
<span className={tx.status === 1 ? 'text-green-600' : 'text-red-600'}>
|
||||
{tx.status === 1 ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading transactions...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Transactions</h1>
|
||||
|
||||
<Table columns={columns} data={transactions} />
|
||||
|
||||
<div className="mt-6 flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-4 py-2">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-4 py-2 bg-gray-200 rounded"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
48
frontend/src/services/api/blocks.ts
Normal file
48
frontend/src/services/api/blocks.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { apiClient, ApiResponse } from './client'
|
||||
|
||||
export interface Block {
|
||||
chain_id: number
|
||||
number: number
|
||||
hash: string
|
||||
timestamp: string
|
||||
miner: string
|
||||
transaction_count: number
|
||||
gas_used: number
|
||||
gas_limit: number
|
||||
}
|
||||
|
||||
export interface BlockListParams {
|
||||
chain_id: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
min_block?: number
|
||||
max_block?: number
|
||||
miner?: string
|
||||
sort?: string
|
||||
order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export const blocksApi = {
|
||||
list: async (params: BlockListParams): Promise<ApiResponse<Block[]>> => {
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('chain_id', params.chain_id.toString())
|
||||
if (params.page) queryParams.append('page', params.page.toString())
|
||||
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||
if (params.min_block) queryParams.append('min_block', params.min_block.toString())
|
||||
if (params.max_block) queryParams.append('max_block', params.max_block.toString())
|
||||
if (params.miner) queryParams.append('miner', params.miner)
|
||||
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()}`)
|
||||
},
|
||||
|
||||
getByNumber: async (chainId: number, number: number): Promise<ApiResponse<Block>> => {
|
||||
return apiClient.get<Block>(`/api/v1/blocks/${chainId}/${number}`)
|
||||
},
|
||||
|
||||
getByHash: async (chainId: number, hash: string): Promise<ApiResponse<Block>> => {
|
||||
return apiClient.get<Block>(`/api/v1/blocks/${chainId}/hash/${hash}`)
|
||||
},
|
||||
}
|
||||
|
||||
92
frontend/src/services/api/client.ts
Normal file
92
frontend/src/services/api/client.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
meta?: {
|
||||
pagination?: {
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
total_pages: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
details?: unknown
|
||||
request_id?: string
|
||||
}
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance
|
||||
|
||||
constructor(baseURL: string = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080') {
|
||||
this.client = axios.create({
|
||||
baseURL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
// Add API key if available
|
||||
const apiKey = this.getApiKey()
|
||||
if (apiKey) {
|
||||
config.headers['X-API-Key'] = apiKey
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Handle errors
|
||||
if (error.response) {
|
||||
const apiError: ApiError = error.response.data
|
||||
return Promise.reject(apiError)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private getApiKey(): string | null {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('api_key')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async get<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response: AxiosResponse<ApiResponse<T>> = await this.client.get(url, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response: AxiosResponse<ApiResponse<T>> = await this.client.post(url, data, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response: AxiosResponse<ApiResponse<T>> = await this.client.put(url, data, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response: AxiosResponse<ApiResponse<T>> = await this.client.delete(url, config)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
|
||||
Reference in New Issue
Block a user