From a1ce7818bf0b5fc3af3a9f890d778c7862e6f9d1 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 17:14:11 -0300 Subject: [PATCH 1/4] feat(blocks/tx): display raw blob data content via Beacon API Add EIP-4844 blob data display by fetching from the Beacon Chain API. Super user mode only. Includes BeaconService for blob sidecar fetching, KZG commitment to versioned hash matching via Web Crypto SHA-256, configurable Beacon API endpoints in Settings, and blob viewer with hex/UTF-8 toggle. Block page shows all blobs in a collapsible section, transaction page adds a Blob Data tab in the TX Analyser. Default public Beacon API endpoints provided for Ethereum and Sepolia. Blob pruning (~18 days) handled gracefully with clear user messaging. i18n translations added for all 5 supported languages. Closes #301 --- src/components/common/BlobDataDisplay.tsx | 105 ++++++++++++++++++ .../pages/evm/block/BlockDisplay.tsx | 45 ++++++++ .../pages/evm/tx/TransactionDisplay.tsx | 2 + src/components/pages/evm/tx/TxAnalyser.tsx | 39 ++++++- src/components/pages/evm/tx/analyser/types.ts | 10 +- src/components/pages/settings/index.tsx | 31 ++++++ src/config/beaconConfig.ts | 44 ++++++++ src/hooks/useBeaconBlobs.ts | 93 ++++++++++++++++ src/locales/en/block.json | 6 + src/locales/en/settings.json | 6 + src/locales/en/transaction.json | 14 +++ src/locales/es/block.json | 6 + src/locales/es/settings.json | 6 + src/locales/es/transaction.json | 14 +++ src/locales/ja/block.json | 6 + src/locales/ja/settings.json | 6 + src/locales/ja/transaction.json | 14 +++ src/locales/pt-BR/block.json | 6 + src/locales/pt-BR/settings.json | 6 + src/locales/pt-BR/transaction.json | 14 +++ src/locales/zh/block.json | 6 + src/locales/zh/settings.json | 6 + src/locales/zh/transaction.json | 14 +++ src/services/BeaconService.ts | 92 +++++++++++++++ src/styles/components.css | 92 +++++++++++++++ src/types/index.ts | 14 +++ 26 files changed, 695 insertions(+), 2 deletions(-) create mode 100644 src/components/common/BlobDataDisplay.tsx create mode 100644 src/config/beaconConfig.ts create mode 100644 src/hooks/useBeaconBlobs.ts create mode 100644 src/services/BeaconService.ts diff --git a/src/components/common/BlobDataDisplay.tsx b/src/components/common/BlobDataDisplay.tsx new file mode 100644 index 00000000..fe7a09b5 --- /dev/null +++ b/src/components/common/BlobDataDisplay.tsx @@ -0,0 +1,105 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { BlobSidecar } from "../../types"; +import CopyButton from "./CopyButton"; +import LongString from "./LongString"; + +interface BlobDataDisplayProps { + blob: BlobSidecar; + index: number; +} + +const BlobDataDisplay: React.FC = React.memo(({ blob, index }) => { + const { t } = useTranslation("transaction"); + const [showUtf8, setShowUtf8] = useState(false); + const [expanded, setExpanded] = useState(false); + + const effectiveSize = useMemo(() => { + const hex = blob.blob.startsWith("0x") ? blob.blob.slice(2) : blob.blob; + let end = hex.length; + while (end > 0 && hex[end - 1] === "0" && hex[end - 2] === "0") { + end -= 2; + } + return end / 2; + }, [blob.blob]); + + const utf8Content = useMemo(() => { + if (!showUtf8) return null; + try { + const hex = blob.blob.startsWith("0x") ? blob.blob.slice(2) : blob.blob; + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + let end = bytes.length; + while (end > 0 && bytes[end - 1] === 0) end--; + return new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, end)); + } catch { + return null; + } + }, [blob.blob, showUtf8]); + + const displayHex = expanded + ? blob.blob + : `${blob.blob.slice(0, 200)}${blob.blob.length > 200 ? "..." : ""}`; + + return ( +
+
+ {t("blobData.blobIndex", { index })} + + {t("blobData.effectiveSize", { bytes: effectiveSize.toLocaleString() })} + +
+ +
+
+ {t("blobData.kzgCommitment")} + + + + +
+
+ {t("blobData.kzgProof")} + + + + +
+
+ +
+
+ + + + +
+ + {showUtf8 && utf8Content ? ( +
{utf8Content}
+ ) : ( +
{displayHex}
+ )} +
+
+ ); +}); + +BlobDataDisplay.displayName = "BlobDataDisplay"; +export default BlobDataDisplay; diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index c8de7882..be6e09c6 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -2,8 +2,11 @@ import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { getNetworkById } from "../../../../config/networks"; +import { useSettings } from "../../../../context/SettingsContext"; +import { useBeaconBlobs } from "../../../../hooks/useBeaconBlobs"; import type { Block, BlockArbitrum, RPCMetadata } from "../../../../types"; import AIAnalysisPanel from "../../../common/AIAnalysis/AIAnalysisPanel"; +import BlobDataDisplay from "../../../common/BlobDataDisplay"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; import LongString from "../../../common/LongString"; import { RPCIndicator } from "../../../common/RPCIndicator"; @@ -20,12 +23,26 @@ interface BlockDisplayProps { const BlockDisplay: React.FC = React.memo( ({ block, networkId, metadata, selectedProvider, onProviderSelect }) => { const { t } = useTranslation("block"); + const { isSuperUser } = useSettings(); const network = networkId ? getNetworkById(networkId) : undefined; const networkName = network?.name ?? "Unknown Network"; const networkCurrency = network?.currency ?? "ETH"; const [showWithdrawals, setShowWithdrawals] = useState(false); const [showTransactions, setShowTransactions] = useState(false); const [showMoreDetails, setShowMoreDetails] = useState(false); + const [showBlobData, setShowBlobData] = useState(false); + + const hasBlobGas = block.blobGasUsed && Number(block.blobGasUsed) > 0; + const caip2NetworkId = networkId ? `eip155:${networkId}` : undefined; + const { + blobs: blobSidecars, + loading: blobsLoading, + isPruned: blobsPruned, + isAvailable: beaconAvailable, + } = useBeaconBlobs( + isSuperUser && hasBlobGas ? caip2NetworkId : undefined, + isSuperUser && hasBlobGas && showBlobData ? Number(block.timestamp) : undefined, + ); // Check if this is an Arbitrum block const isArbitrumBlock = (block: Block | BlockArbitrum): block is BlockArbitrum => { @@ -498,6 +515,34 @@ const BlockDisplay: React.FC = React.memo( )} )} + {/* Blob Data (Super User only) */} + {isSuperUser && hasBlobGas && beaconAvailable && ( +
+
+ {/** biome-ignore lint/a11y/useButtonType: */} + +
+ {showBlobData && ( +
+ {blobsLoading &&

{t("blobData.loading")}

} + {blobsPruned &&

{t("blobData.pruned")}

} + {blobSidecars?.map((blob) => ( + + ))} +
+ )} +
+ )} = React.memo( inputData={transaction.data} decodedInputData={decodedInput} isSuperUser={isSuperUser} + blobVersionedHashes={transaction.blobVersionedHashes} + blockTimestamp={transaction.timestamp ? Number(transaction.timestamp) : undefined} /> )} diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index ff570233..f8c9f587 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -2,10 +2,12 @@ import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { CallNode, PrestateTrace } from "../../../../services/adapters/NetworkAdapter"; +import { useBeaconBlobs } from "../../../../hooks/useBeaconBlobs"; import { useCallTreeEnrichment } from "../../../../hooks/useCallTreeEnrichment"; import { type ContractInfo, fetchContractInfoBatch } from "../../../../utils/contractLookup"; import { useSettings } from "../../../../context/SettingsContext"; import { logger } from "../../../../utils/logger"; +import BlobDataDisplay from "../../../common/BlobDataDisplay"; import type { AnalyserTab, TxAnalyserProps } from "./analyser/types"; import CallTreeTab from "./analyser/CallTreeTab"; import StateChangesTab from "./analyser/StateChangesTab"; @@ -24,20 +26,36 @@ const TxAnalyser: React.FC = ({ inputData, decodedInputData, isSuperUser, + blobVersionedHashes, + blockTimestamp, }) => { const { t } = useTranslation("transaction"); const hasEvents = logs && logs.length > 0; const hasInputData = inputData && inputData !== "0x"; + const hasBlobData = blobVersionedHashes && blobVersionedHashes.length > 0; const defaultTab: AnalyserTab = hasEvents ? "events" : hasInputData ? "inputData" : "callTree"; const [activeTab, setActiveTab] = useState(defaultTab); + // Beacon blob data + const caip2NetworkId = networkId ? `eip155:${networkId}` : undefined; + const { + blobs: blobSidecars, + loading: blobsLoading, + isPruned: blobsPruned, + isAvailable: beaconAvailable, + } = useBeaconBlobs( + isSuperUser && hasBlobData ? caip2NetworkId : undefined, + isSuperUser && hasBlobData && activeTab === "blobData" ? blockTimestamp : undefined, + blobVersionedHashes, + ); + // Reset to a base tab when leaving super user mode // biome-ignore lint/correctness/useExhaustiveDependencies: only react to isSuperUser changes useEffect(() => { if (isSuperUser) { setCollapsed(false); } else { - const superTabs: AnalyserTab[] = ["callTree", "gasProfiler", "stateChanges"]; + const superTabs: AnalyserTab[] = ["callTree", "gasProfiler", "stateChanges", "blobData"]; setActiveTab((prev) => (superTabs.includes(prev) ? defaultTab : prev)); } }, [isSuperUser]); @@ -228,6 +246,15 @@ const TxAnalyser: React.FC = ({ > {t("analyser.stateChanges")} + {hasBlobData && beaconAvailable && ( + + )} )} + {hasTxs && ( + + )} + {hasWithdrawals && ( + + )} + {showBlobTab && ( + + )} + + + + {/* Tab content */} + {!collapsed && ( +
+ {activeTab === "moreDetails" && ( +
+
+ {t("analyser.parentHash")} + + {networkId && + block.parentHash !== + "0x0000000000000000000000000000000000000000000000000000000000000000" ? ( + + {block.parentHash} + + ) : ( + block.parentHash + )} + +
+
+ {t("analyser.stateRoot")} + {block.stateRoot} +
+
+ {t("analyser.transactionsRoot")} + {block.transactionsRoot} +
+
+ {t("analyser.receiptsRoot")} + {block.receiptsRoot} +
+ {block.withdrawalsRoot && ( +
+ {t("analyser.withdrawalsRoot")} + {block.withdrawalsRoot} +
+ )} +
+ {t("analyser.logsBloom")} +
+ {block.logsBloom} +
+
+
+ {t("analyser.nonce")} + {block.nonce} +
+
+ {t("analyser.mixHash")} + {block.mixHash} +
+
+ {t("analyser.sha3Uncles")} + {block.sha3Uncles} +
+
+ )} + + {activeTab === "transactions" && hasTxs && ( +
+ {block.transactions.map((txHash, index) => ( +
+ {index} + + {networkId ? ( + + {txHash} + + ) : ( + txHash + )} + +
+ ))} +
+ )} + + {activeTab === "withdrawals" && hasWithdrawals && ( +
+ {block.withdrawals.map((withdrawal, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: withdrawal index is stable +
+
{index}
+
+ {t("index")} + {Number(withdrawal.index).toLocaleString()} +
+
+ {t("validator")} + {Number(withdrawal.validatorIndex).toLocaleString()} +
+
+ {networkId ? ( + + {withdrawal.address} + + ) : ( + withdrawal.address + )} +
+
+ {(Number(withdrawal.amount) / 1e9).toFixed(9)} ETH +
+
+ ))} +
+ )} + + {activeTab === "blobData" && showBlobTab && ( +
+ {blobsLoading &&
{t("blobData.loading")}
} + {blobsPruned &&
{t("blobData.pruned")}
} + {blobSidecars?.map((blob) => ( + + ))} +
+ )} +
+ )} + + ); +}; + +export default BlockAnalyser; diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index be6e09c6..61ae6fd2 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -1,16 +1,15 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { getNetworkById } from "../../../../config/networks"; import { useSettings } from "../../../../context/SettingsContext"; -import { useBeaconBlobs } from "../../../../hooks/useBeaconBlobs"; import type { Block, BlockArbitrum, RPCMetadata } from "../../../../types"; import AIAnalysisPanel from "../../../common/AIAnalysis/AIAnalysisPanel"; -import BlobDataDisplay from "../../../common/BlobDataDisplay"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; import LongString from "../../../common/LongString"; import { RPCIndicator } from "../../../common/RPCIndicator"; import { formatGweiFromWei, formatNativeFromWei } from "../../../../utils/unitFormatters"; +import BlockAnalyser from "./BlockAnalyser"; interface BlockDisplayProps { block: Block | BlockArbitrum; @@ -27,22 +26,6 @@ const BlockDisplay: React.FC = React.memo( const network = networkId ? getNetworkById(networkId) : undefined; const networkName = network?.name ?? "Unknown Network"; const networkCurrency = network?.currency ?? "ETH"; - const [showWithdrawals, setShowWithdrawals] = useState(false); - const [showTransactions, setShowTransactions] = useState(false); - const [showMoreDetails, setShowMoreDetails] = useState(false); - const [showBlobData, setShowBlobData] = useState(false); - - const hasBlobGas = block.blobGasUsed && Number(block.blobGasUsed) > 0; - const caip2NetworkId = networkId ? `eip155:${networkId}` : undefined; - const { - blobs: blobSidecars, - loading: blobsLoading, - isPruned: blobsPruned, - isAvailable: beaconAvailable, - } = useBeaconBlobs( - isSuperUser && hasBlobGas ? caip2NetworkId : undefined, - isSuperUser && hasBlobGas && showBlobData ? Number(block.timestamp) : undefined, - ); // Check if this is an Arbitrum block const isArbitrumBlock = (block: Block | BlockArbitrum): block is BlockArbitrum => { @@ -351,198 +334,9 @@ const BlockDisplay: React.FC = React.memo( )} - - {/* Full-width: More Details (collapsible) */} -
- {/** biome-ignore lint/a11y/useButtonType: */} - - - {showMoreDetails && ( -
-
- Parent Hash: - - {networkId && - block.parentHash !== - "0x0000000000000000000000000000000000000000000000000000000000000000" ? ( - - {block.parentHash} - - ) : ( - block.parentHash - )} - -
-
- State Root: - {block.stateRoot} -
-
- Transactions Root: - {block.transactionsRoot} -
-
- Receipts Root: - {block.receiptsRoot} -
- {block.withdrawalsRoot && ( -
- Withdrawals Root: - {block.withdrawalsRoot} -
- )} -
- Logs Bloom: -
- {block.logsBloom} -
-
-
- Nonce: - {block.nonce} -
-
- Mix Hash: - {block.mixHash} -
-
- Sha3 Uncles: - {block.sha3Uncles} -
-
- )} -
- {/* Transactions List */} - {block.transactions && block.transactions.length > 0 && ( -
-
- {/** biome-ignore lint/a11y/useButtonType: */} - -
- {showTransactions && ( -
- {block.transactions.map((txHash, index) => ( -
- {index} - - {networkId ? ( - - {txHash} - - ) : ( - txHash - )} - -
- ))} -
- )} -
- )} - - {/* Withdrawals List */} - {block.withdrawals && block.withdrawals.length > 0 && ( -
-
- {/** biome-ignore lint/a11y/useButtonType: */} - -
- {showWithdrawals && ( -
- {block.withdrawals.map((withdrawal, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
-
{index}
-
-
- {t("index")} - - {Number(withdrawal.index).toLocaleString()} - -
-
- {t("validator")} - - {Number(withdrawal.validatorIndex).toLocaleString()} - -
-
- {t("address")} - - {networkId ? ( - - {withdrawal.address} - - ) : ( - withdrawal.address - )} - -
-
- {t("amount")} - - {(Number(withdrawal.amount) / 1e9).toFixed(9)} ETH - -
-
-
- ))} -
- )} -
- )} - {/* Blob Data (Super User only) */} - {isSuperUser && hasBlobGas && beaconAvailable && ( -
-
- {/** biome-ignore lint/a11y/useButtonType: */} - -
- {showBlobData && ( -
- {blobsLoading &&

{t("blobData.loading")}

} - {blobsPruned &&

{t("blobData.pruned")}

} - {blobSidecars?.map((blob) => ( - - ))} -
- )} -
- )} + = ({ ); return ( -
+
{/* Tab bar */} -
+
{hasEvents && ( - diff --git a/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx b/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx index 73db3e88..252b0762 100644 --- a/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx +++ b/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx @@ -154,8 +154,8 @@ const GasProfilerTab: React.FC<{ : []; return ( -
-
+
+
{t("analyser.summaryGas", { gas: totalGas.toLocaleString() })} {isZoomed && ( - diff --git a/src/locales/en/block.json b/src/locales/en/block.json index e70450aa..bd5bcba6 100644 --- a/src/locales/en/block.json +++ b/src/locales/en/block.json @@ -66,6 +66,23 @@ "minute": "minute", "minute_other": "minutes" }, + "analyser": { + "moreDetails": "More Details", + "transactions": "Transactions", + "withdrawals": "Withdrawals", + "blobData": "Blob Data", + "expand": "Expand", + "collapse": "Collapse", + "parentHash": "Parent Hash:", + "stateRoot": "State Root:", + "transactionsRoot": "Transactions Root:", + "receiptsRoot": "Receipts Root:", + "withdrawalsRoot": "Withdrawals Root:", + "logsBloom": "Logs Bloom:", + "nonce": "Nonce:", + "mixHash": "Mix Hash:", + "sha3Uncles": "Sha3 Uncles:" + }, "aiAnalysis": { "sectionTitle": "AI Analysis" }, diff --git a/src/locales/es/block.json b/src/locales/es/block.json index b4de2273..4a1fd4cf 100644 --- a/src/locales/es/block.json +++ b/src/locales/es/block.json @@ -66,6 +66,23 @@ "minute": "minuto", "minute_other": "minutos" }, + "analyser": { + "moreDetails": "Más detalles", + "transactions": "Transacciones", + "withdrawals": "Retiros", + "blobData": "Datos de Blobs", + "expand": "Expandir", + "collapse": "Colapsar", + "parentHash": "Hash padre:", + "stateRoot": "Raíz de estado:", + "transactionsRoot": "Raíz de transacciones:", + "receiptsRoot": "Raíz de recibos:", + "withdrawalsRoot": "Raíz de retiros:", + "logsBloom": "Logs Bloom:", + "nonce": "Nonce:", + "mixHash": "Mix Hash:", + "sha3Uncles": "Sha3 Uncles:" + }, "aiAnalysis": { "sectionTitle": "Análisis IA" }, diff --git a/src/styles/components.css b/src/styles/components.css index 0273a4c5..a4f979b0 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -435,6 +435,75 @@ } } +/* Withdrawal list (block analyser) */ +.withdrawal-list { + display: flex; + flex-direction: column; +} + +.withdrawal-row { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + border-bottom: 1px solid var(--color-primary-alpha-10); + font-size: 0.85rem; +} + +.withdrawal-row:last-child { + border-bottom: none; +} + +.withdrawal-index { + flex: 0 0 40px; + height: 28px; + background: var(--color-primary-alpha-10); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.withdrawal-field { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.withdrawal-field-label { + font-size: 0.75rem; + color: var(--text-tertiary); + white-space: nowrap; +} + +.withdrawal-address { + flex: 1; + min-width: 0; + word-break: break-all; +} + +.withdrawal-amount { + flex: 0 0 auto; + font-weight: 600; + white-space: nowrap; +} + +@media (max-width: 768px) { + .withdrawal-row { + flex-wrap: wrap; + gap: 8px; + } + + .withdrawal-address { + flex-basis: 100%; + order: 10; + } +} + .contract-creation-badge { display: inline-block; padding: 2px 8px; @@ -1947,9 +2016,9 @@ button.tx-section-header-toggle { padding: 10px; } -/* ── TX Analyser ─────────────────────────────────────────────────────────── */ +/* ── Detail Panel (shared: TX Analyser, Block Analyser) ─────────────────── */ -.tx-analyser { +.detail-panel { margin-top: 16px; border: 1px solid var(--border-primary); border-radius: 10px; @@ -1957,14 +2026,14 @@ button.tx-section-header-toggle { background: var(--bg-secondary); } -.tx-analyser-tabs { +.detail-panel-tabs { display: flex; align-items: center; border-bottom: 1px solid var(--border-primary); background: var(--bg-tertiary); } -.tx-analyser-collapse-btn { +.detail-panel-collapse-btn { margin-left: auto; padding: 6px 12px; background: none; @@ -1974,11 +2043,11 @@ button.tx-section-header-toggle { font-size: 0.85rem; } -.tx-analyser-collapse-btn:hover { +.detail-panel-collapse-btn:hover { color: var(--text-primary); } -.tx-analyser-tab { +.detail-panel-tab { padding: 10px 18px; font-size: 0.875rem; font-weight: 500; @@ -1991,38 +2060,38 @@ button.tx-section-header-toggle { margin-bottom: -1px; } -.tx-analyser-tab:hover { +.detail-panel-tab:hover { color: var(--text-primary); } -.tx-analyser-tab--active { +.detail-panel-tab--active { color: var(--color-accent); border-bottom-color: var(--color-accent); } -.tx-analyser-tab--active-base { +.detail-panel-tab--active-base { color: var(--text-primary); border-bottom-color: var(--text-primary); } -.tx-analyser-body { +.detail-panel-body { padding: 16px; min-height: 80px; } -.analyser-tab-content { +.detail-panel-tab-content { display: flex; flex-direction: column; gap: 12px; } -.analyser-loading { +.detail-panel-loading { text-align: center; padding: 32px; color: var(--text-secondary); } -.analyser-error { +.detail-panel-error { padding: 16px; border-radius: 8px; background: rgba(239, 68, 68, 0.08); @@ -2031,20 +2100,20 @@ button.tx-section-header-toggle { font-size: 0.875rem; } -.analyser-hint { +.detail-panel-hint { margin-top: 8px; color: var(--text-secondary); font-size: 0.8rem; } -.analyser-empty { +.detail-panel-empty { text-align: center; padding: 32px; color: var(--text-tertiary); font-size: 0.875rem; } -.analyser-summary { +.detail-panel-summary { display: flex; gap: 16px; flex-wrap: wrap; @@ -2056,7 +2125,7 @@ button.tx-section-header-toggle { border: 1px solid var(--border-primary); } -.analyser-summary-reverts { +.detail-panel-summary-reverts { color: var(--color-error); } @@ -2190,13 +2259,13 @@ button.tx-section-header-toggle { background: var(--overlay-light-2); } -.analyser-expand-controls { +.detail-panel-expand-controls { margin-left: auto; display: flex; gap: 8px; } -.analyser-expand-btn { +.detail-panel-expand-btn { background: none; border: 1px solid var(--border-primary); border-radius: 4px; @@ -2206,7 +2275,7 @@ button.tx-section-header-toggle { cursor: pointer; } -.analyser-expand-btn:hover { +.detail-panel-expand-btn:hover { color: var(--text-primary); border-color: var(--text-tertiary); } @@ -2319,12 +2388,12 @@ button.tx-section-header-toggle { color: var(--text-secondary); } -.analyser-summary-type { +.detail-panel-summary-type { font-size: 0.8rem; font-weight: 500; } -.analyser-summary-loading { +.detail-panel-summary-loading { color: var(--text-tertiary); font-size: 0.8rem; font-style: italic; From 78436d19aa1da3d44e6c83eba069e0ae42e58dae Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 17:39:28 -0300 Subject: [PATCH 3/4] merge: resolve conflicts with upstream dev Merge openscan-explorer/dev into feat/beacon-blob-data, incorporating helper tooltips, knowledge levels, raw trace tab, FieldLabel component, Hardhat adapter, and Etherscan worker proxy alongside our beacon blob and block analyser changes. --- .claude/rules/architecture.md | 10 +- .claude/rules/commands.md | 2 +- .claude/rules/patterns.md | 11 +- README.md | 27 +- bun.lock | 6 +- package.json | 2 +- scripts/run-test-env.sh | 2 +- src/App.tsx | 1 + src/components/common/FieldLabel.tsx | 35 ++ src/components/common/HelperTooltip.tsx | 279 ++++++++++++++ src/components/navbar/index.tsx | 85 ++++- .../pages/bitcoin/BitcoinAddressDisplay.tsx | 27 +- .../pages/bitcoin/BitcoinBlockDisplay.tsx | 125 ++++++- .../bitcoin/BitcoinTransactionDisplay.tsx | 172 ++++++--- .../address/shared/AccountMoreInfoCard.tsx | 15 +- .../address/shared/AccountOverviewCard.tsx | 29 +- .../evm/address/shared/AddressHeader.tsx | 11 +- .../evm/address/shared/ContractDetails.tsx | 19 +- .../evm/address/shared/ContractInfoCard.tsx | 22 +- .../address/shared/ContractInteraction.tsx | 17 +- .../address/shared/ContractMoreInfoCard.tsx | 15 +- .../evm/address/shared/ERC20TokenInfoCard.tsx | 15 +- .../address/shared/NFTCollectionInfoCard.tsx | 22 +- .../evm/address/shared/TransactionHistory.tsx | 55 ++- .../pages/evm/block/BlockDisplay.tsx | 116 ++++-- .../pages/evm/network/DashboardStats.tsx | 18 +- .../pages/evm/network/NetworkStatsDisplay.tsx | 31 +- .../evm/tokenDetails/ERC1155TokenDisplay.tsx | 13 +- .../evm/tokenDetails/ERC721TokenDisplay.tsx | 31 +- .../pages/evm/tx/TransactionDisplay.tsx | 295 +++++++-------- src/components/pages/evm/tx/TxAnalyser.tsx | 94 ++++- .../pages/evm/tx/analyser/RawTraceTab.tsx | 167 +++++++++ .../pages/evm/tx/analyser/StateChangesTab.tsx | 28 +- src/components/pages/evm/tx/analyser/types.ts | 1 + src/components/pages/settings/index.tsx | 72 ++++ src/config/networks.json | 52 +++ src/context/AppContext.tsx | 35 +- src/hooks/useContractVerification.ts | 8 +- src/hooks/useEtherscan.ts | 38 +- src/i18n.ts | 11 + src/i18next.d.ts | 2 + src/locales/en/common.json | 9 +- src/locales/en/settings.json | 15 + src/locales/en/tooltips.json | 161 +++++++++ src/locales/en/transaction.json | 7 +- src/locales/es/common.json | 9 +- src/locales/es/settings.json | 15 + src/locales/es/tooltips.json | 161 +++++++++ src/locales/es/transaction.json | 7 +- src/locales/ja/common.json | 9 +- src/locales/ja/settings.json | 15 + src/locales/ja/tooltips.json | 161 +++++++++ src/locales/ja/transaction.json | 7 +- src/locales/pt-BR/common.json | 9 +- src/locales/pt-BR/settings.json | 15 + src/locales/pt-BR/tooltips.json | 161 +++++++++ src/locales/pt-BR/transaction.json | 7 +- src/locales/zh/common.json | 9 +- src/locales/zh/settings.json | 15 + src/locales/zh/tooltips.json | 161 +++++++++ src/locales/zh/transaction.json | 7 +- .../adapters/BitcoinAdapter/BitcoinAdapter.ts | 16 +- .../adapters/EVMAdapter/EVMAdapter.ts | 5 +- .../adapters/HardhatAdapter/HardhatAdapter.ts | 339 ++++++++++++++++++ src/services/adapters/NetworkAdapter.ts | 5 +- src/services/adapters/adaptersFactory.ts | 13 +- src/styles/components.css | 182 ++++++++++ src/styles/helper-tooltip.css | 120 +++++++ src/styles/styles.css | 3 +- src/types/index.ts | 19 +- src/utils/contractLookup.ts | 82 +++-- src/utils/structLogConverter.ts | 314 ++++++++++++++++ worker/src/index.ts | 11 + worker/src/middleware/rateLimitEtherscan.ts | 49 +++ worker/src/middleware/validateEtherscan.ts | 24 ++ worker/src/routes/etherscanVerify.ts | 35 ++ worker/src/types.ts | 6 + worker/wrangler.toml | 6 +- 78 files changed, 3787 insertions(+), 418 deletions(-) create mode 100644 src/components/common/FieldLabel.tsx create mode 100644 src/components/common/HelperTooltip.tsx create mode 100644 src/components/pages/evm/tx/analyser/RawTraceTab.tsx create mode 100644 src/locales/en/tooltips.json create mode 100644 src/locales/es/tooltips.json create mode 100644 src/locales/ja/tooltips.json create mode 100644 src/locales/pt-BR/tooltips.json create mode 100644 src/locales/zh/tooltips.json create mode 100644 src/services/adapters/HardhatAdapter/HardhatAdapter.ts create mode 100644 src/styles/helper-tooltip.css create mode 100644 src/utils/structLogConverter.ts create mode 100644 worker/src/middleware/rateLimitEtherscan.ts create mode 100644 worker/src/middleware/validateEtherscan.ts create mode 100644 worker/src/routes/etherscanVerify.ts diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index cf1ade9f..706f0eef 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -25,7 +25,7 @@ Orchestrates data fetching with caching and metadata: - Instantiates network-specific fetchers/adapters based on chain ID - Returns `DataWithMetadata` when using parallel strategy - 30-second in-memory cache keyed by `networkId:type:identifier` -- Supports trace operations for localhost networks only +- Supports trace operations for Hardhat (31337) and localhost networks ### 5. Hook Layer (`hooks/`) React integration: @@ -42,10 +42,10 @@ Global state management: Chain ID detection in `DataService` constructor determines which adapters/fetchers to use: -- **Arbitrum** (42161): `BlockFetcherArbitrum`, `BlockArbitrumAdapter` - adds `l1BlockNumber`, `sendCount`, `requestId` -- **OP Stack** (10, 8453): Optimism (10), Base (8453) - adds L1 fee breakdown (`l1Fee`, `l1GasPrice`, `l1GasUsed`) -- **Localhost** (31337): All networks + trace support (`debug_traceTransaction`, `trace_block`, etc.) -- **Default**: L1 fetchers/adapters for Ethereum (1), BSC (56, 97), Polygon (137), Sepolia (11155111) +- **Arbitrum** (42161): `ArbitrumAdapter` - adds `l1BlockNumber`, `sendCount`, `requestId` +- **OP Stack** (10, 8453): `OptimismAdapter` (10), `BaseAdapter` (8453) - adds L1 fee breakdown (`l1Fee`, `l1GasPrice`, `l1GasUsed`) +- **Hardhat** (31337): `HardhatAdapter` - uses `HardhatClient` from `@openscan/network-connectors`; trace support via struct log conversion (`buildCallTreeFromStructLogs`, `buildPrestateFromStructLogs` in `src/utils/structLogConverter.ts`) since Hardhat v3 does not support `callTracer`/`prestateTracer` +- **Default**: `EVMAdapter` for Ethereum (1), BSC (56, 97), Polygon (137), Sepolia (11155111), Avalanche (43114) ## Key Type Definitions diff --git a/.claude/rules/commands.md b/.claude/rules/commands.md index b746d64d..d65e9750 100644 --- a/.claude/rules/commands.md +++ b/.claude/rules/commands.md @@ -93,4 +93,4 @@ REACT_APP_OPENSCAN_NETWORKS="1,31337" npm start npm start ``` -Supported chain IDs: 1 (Ethereum), 42161 (Arbitrum), 10 (Optimism), 8453 (Base), 56 (BSC), 137 (Polygon), 31337 (Localhost), 97 (BSC Testnet), 11155111 (Sepolia) +Supported chain IDs: 1 (Ethereum), 42161 (Arbitrum), 10 (Optimism), 8453 (Base), 56 (BSC), 137 (Polygon), 31337 (Hardhat), 97 (BSC Testnet), 11155111 (Sepolia), 43114 (Avalanche) diff --git a/.claude/rules/patterns.md b/.claude/rules/patterns.md index 8d9e0d11..cd5aec9d 100644 --- a/.claude/rules/patterns.md +++ b/.claude/rules/patterns.md @@ -32,10 +32,10 @@ 1. Add chain ID to `src/types/index.ts` if creating new domain types 2. Add default RPC endpoints to `src/config/rpcConfig.ts` -3. Determine if network needs custom fetchers/adapters (L1, Arbitrum-like, OP Stack-like) -4. If custom: create `src/services/EVM/[Network]/fetchers/` and `adapters/` -5. Update `DataService` constructor to detect chain ID and instantiate correct fetchers/adapters -6. Add network config to `ALL_NETWORKS` in `src/config/networks.ts` +3. Determine if network needs a custom adapter (L1, Arbitrum-like, OP Stack-like, Hardhat-like) +4. If custom: create `src/services/adapters/[Network]Adapter/[Network]Adapter.ts` +5. Register the adapter in `src/services/adapters/adaptersFactory.ts` with its chain ID +6. Add network config to `src/config/networks.json` 7. Add network logo to `public/` and update `logoType` in network config ## Testing with Local Networks @@ -43,7 +43,8 @@ OpenScan includes special support for localhost development: - **Hardhat 3 Ignition**: Import deployment artifacts via Settings → Import Ignition Deployment -- **Trace Support**: `debug_traceTransaction`, `debug_traceBlockByHash`, `debug_traceCall` available on localhost (31337) +- **Trace Support**: `debug_traceTransaction`, `debug_traceBlockByHash`, `debug_traceCall` available on Hardhat (31337) and localhost networks +- **Hardhat Trace Conversion**: Hardhat v3 only supports the default struct log tracer (not `callTracer`/`prestateTracer`). The `HardhatAdapter` uses `buildCallTreeFromStructLogs()` and `buildPrestateFromStructLogs()` from `src/utils/structLogConverter.ts` to convert opcode traces into call trees and state diffs - **Auto-detection**: Port 8545 automatically recognized as localhost network ## Component Patterns diff --git a/README.md b/README.md index db2dab28..b29d95e2 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ A trustless, open-source blockchain explorer for Bitcoin, Ethereum, and Layer 2 - **BSC (BNB Chain)** - Binance Smart Chain mainnet - **BSC Testnet** - Binance Smart Chain testnet - **Polygon POS** - Polygon proof-of-stake mainnet -- **Localhost** - Local development networks (Hardhat/Anvil) +- **Hardhat** - Local development network (Chain ID 31337) with trace support +- **Localhost** - Local development networks (Anvil/other) ### 🔍 Core Functionality @@ -59,6 +60,13 @@ A trustless, open-source blockchain explorer for Bitcoin, Ethereum, and Layer 2 - **Multiple Fallback URLs** - Automatic failover to backup RPC providers - **Read/Write Operations** - Execute smart contract calls on verified smart contracts. +### 🔬 Hardhat Development Support + +- **Dedicated Adapter** - Full `HardhatAdapter` for chain ID 31337 with typed `HardhatClient` +- **Trace Methods** - Call Tree, Gas Profiler, and State Changes via struct log conversion (Hardhat does not support Geth's `callTracer`/`prestateTracer`, so opcode-level traces are converted) +- **HH3 Ignition** - Import Hardhat 3 Ignition deployment artifacts to inspect and interact with contracts +- **Auto-detection** - Port 8545 automatically recognized as Hardhat network + ### ⚡ Layer 2 Support - **Arbitrum-Specific Fields** - Display L1 block numbers, send counts, and request IDs @@ -276,13 +284,16 @@ src/ ├── context/ # React context providers ├── hooks/ # Custom React hooks ├── services/ # Blockchain data services -│ ├── adapters/ # General reusable adapters -│ │ └── BitcoinAdapter/ # Bitcoin network adapter -│ └── EVM/ # EVM-compatible chain adapters -│ ├── Arbitrum/ # Arbitrum-specific adapters -│ ├── common/ # EVM common resources -│ ├── L1/ # EVM L1 resources -│ └── Optimism/ # Optimism-specific adapters +│ ├── adapters/ # Network adapters +│ │ ├── BitcoinAdapter/ # Bitcoin network adapter +│ │ ├── HardhatAdapter/ # Hardhat local dev adapter (31337) +│ │ ├── EVMAdapter/ # Default EVM adapter (Ethereum, Sepolia, etc.) +│ │ ├── ArbitrumAdapter/ # Arbitrum-specific adapter +│ │ ├── OptimismAdapter/ # Optimism-specific adapter +│ │ ├── BaseAdapter/ # Base-specific adapter +│ │ ├── BNBAdapter/ # BNB Chain adapter +│ │ └── PolygonAdapter/ # Polygon adapter +│ └── EVM/ # EVM-compatible chain adapters (legacy) ├── types/ # TypeScript type definitions ├── utils/ # Utility functions └── styles/ # CSS stylesheets diff --git a/bun.lock b/bun.lock index 780aa3d3..b39d6261 100644 --- a/bun.lock +++ b/bun.lock @@ -1,12 +1,12 @@ { "lockfileVersion": 1, - "configVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "openscan", "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.3.2", + "@openscan/network-connectors": "1.6.0", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", @@ -280,7 +280,7 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@openscan/network-connectors": ["@openscan/network-connectors@1.3.2", "", {}, "sha512-OdH+PqP/VNYkPrXBCaMhjNF2FQ5N5WH9wd9uGelgkCvbXXS0xXRC4PlPqWSSXqjZJUud0HAGDF5pbZfxIPFQnQ=="], + "@openscan/network-connectors": ["@openscan/network-connectors@1.6.0", "", {}, "sha512-f7Ox20+NIJ4DXzcbj0OyyuVjiHB0zjm1xv+yhzrYhh6TfW6bfHpq62+++oF6/5ud5VLptRXSkdaMrLcAOJL0vQ=="], "@paulmillr/qr": ["@paulmillr/qr@0.2.1", "", {}, "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ=="], diff --git a/package.json b/package.json index c68207de..7441e9db 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.3.2", + "@openscan/network-connectors": "1.6.0", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", diff --git a/scripts/run-test-env.sh b/scripts/run-test-env.sh index b5310575..46ebe8a9 100755 --- a/scripts/run-test-env.sh +++ b/scripts/run-test-env.sh @@ -86,7 +86,7 @@ echo "🔍 Starting OpenScan (Ethereum Mainnet + hardhat only)..." cd "$OPENSCAN_DIR" # Start OpenScan - it will read .env.local on start -REACT_APP_OPENSCAN_NETWORKS="1,31337" npm start & +REACT_APP_OPENSCAN_NETWORKS="31337" npm start & OPENSCAN_PID=$! # Wait for OpenScan to start diff --git a/src/App.tsx b/src/App.tsx index d09f0a50..3a70f196 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import "./styles/rainbowkit.css"; import "./styles/responsive.css"; import "./styles/ai-analysis.css"; import "./styles/rpcs.css"; +import "./styles/helper-tooltip.css"; import Loading from "./components/common/Loading"; import { diff --git a/src/components/common/FieldLabel.tsx b/src/components/common/FieldLabel.tsx new file mode 100644 index 00000000..1f01b48d --- /dev/null +++ b/src/components/common/FieldLabel.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from "react-i18next"; +import type { KnowledgeLevel } from "../../types"; +import { useSettings } from "../../context/SettingsContext"; +import HelperTooltip from "./HelperTooltip"; + +interface FieldLabelProps { + label: string; + tooltipKey?: string; + visibleFor?: KnowledgeLevel[]; + className?: string; +} + +const FieldLabel: React.FC = ({ + label, + tooltipKey, + visibleFor, + className = "tx-label", +}) => { + const { settings } = useSettings(); + const { t } = useTranslation("tooltips"); + + const level = settings.knowledgeLevel ?? "beginner"; + const tooltipsEnabled = settings.showHelperTooltips !== false; + + const shouldShow = tooltipsEnabled && tooltipKey && (!visibleFor || visibleFor.includes(level)); + + return ( + + {label} + {shouldShow && } + + ); +}; + +export default FieldLabel; diff --git a/src/components/common/HelperTooltip.tsx b/src/components/common/HelperTooltip.tsx new file mode 100644 index 00000000..58dc1f7e --- /dev/null +++ b/src/components/common/HelperTooltip.tsx @@ -0,0 +1,279 @@ +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface HelperTooltipProps { + content: string; + placement?: "top" | "bottom" | "left" | "right"; + className?: string; +} + +const HOVER_DELAY_MS = 350; + +const HelperTooltip: React.FC = ({ content, placement = "top", className }) => { + const [isVisible, setIsVisible] = useState(false); + const [actualPlacement, setActualPlacement] = useState(placement); + const [triggerRect, setTriggerRect] = useState(null); + const tooltipId = useId(); + const triggerRef = useRef(null); + const bubbleRef = useRef(null); + const arrowRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); + const isPointerInsideRef = useRef(false); + + const show = useCallback(() => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + // Only auto-flip top↔bottom; left/right stay as requested + if (placement === "top" || placement === "bottom") { + setActualPlacement(rect.top < 80 ? "bottom" : placement); + } else { + setActualPlacement(placement); + } + setTriggerRect(rect); + } + setIsVisible(true); + }, [placement]); + + const hide = useCallback(() => { + setIsVisible(false); + }, []); + + const clearHoverTimeout = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }, []); + + const handlePointerEnter = useCallback(() => { + isPointerInsideRef.current = true; + clearHoverTimeout(); + hoverTimeoutRef.current = setTimeout(show, HOVER_DELAY_MS); + }, [show, clearHoverTimeout]); + + const handlePointerLeave = useCallback(() => { + isPointerInsideRef.current = false; + clearHoverTimeout(); + hoverTimeoutRef.current = setTimeout(() => { + if (!isPointerInsideRef.current) { + hide(); + } + }, 100); + }, [hide, clearHoverTimeout]); + + const handleFocus = useCallback(() => { + show(); + }, [show]); + + const handleBlur = useCallback(() => { + hide(); + }, [hide]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + hide(); + } + }, + [hide], + ); + + const handleClick = useCallback(() => { + if (isVisible) { + hide(); + } else { + show(); + } + }, [isVisible, show, hide]); + + // Close on outside click + useEffect(() => { + if (!isVisible) return; + + const handleOutsideClick = (e: MouseEvent | TouchEvent) => { + const target = e.target as Node; + if ( + triggerRef.current && + !triggerRef.current.contains(target) && + bubbleRef.current && + !bubbleRef.current.contains(target) + ) { + hide(); + } + }; + + document.addEventListener("mousedown", handleOutsideClick); + document.addEventListener("touchstart", handleOutsideClick); + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + document.removeEventListener("touchstart", handleOutsideClick); + }; + }, [isVisible, hide]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); + + // Clamp bubble within viewport and position arrow after render + useLayoutEffect(() => { + if (!isVisible || !bubbleRef.current || !triggerRect) return; + const bubble = bubbleRef.current; + const arrow = arrowRef.current; + const rect = bubble.getBoundingClientRect(); + const margin = 8; + let needsClamp = false; + let left = rect.left; + let top = rect.top; + + // Horizontal clamping + if (rect.right > window.innerWidth - margin) { + left = window.innerWidth - margin - rect.width; + needsClamp = true; + } else if (rect.left < margin) { + left = margin; + needsClamp = true; + } + + // Vertical clamping + if (rect.bottom > window.innerHeight - margin) { + top = window.innerHeight - margin - rect.height; + needsClamp = true; + } else if (rect.top < margin) { + top = margin; + needsClamp = true; + } + + if (needsClamp) { + bubble.style.left = `${left}px`; + bubble.style.top = `${top}px`; + bubble.style.transform = "none"; + } + + // Position arrow to point at trigger center + if (arrow) { + const bubbleRect = bubble.getBoundingClientRect(); + const triggerCenterX = triggerRect.left + triggerRect.width / 2; + const triggerCenterY = triggerRect.top + triggerRect.height / 2; + const arrowSize = 5; + + if (actualPlacement === "top" || actualPlacement === "bottom") { + const arrowLeft = Math.max( + arrowSize, + Math.min(triggerCenterX - bubbleRect.left, bubbleRect.width - arrowSize), + ); + arrow.style.left = `${arrowLeft}px`; + } else { + const arrowTop = Math.max( + arrowSize, + Math.min(triggerCenterY - bubbleRect.top, bubbleRect.height - arrowSize), + ); + arrow.style.top = `${arrowTop}px`; + } + } + }, [isVisible, triggerRect, actualPlacement]); + + const getBubbleStyle = (): React.CSSProperties => { + if (!triggerRect) return {}; + const gap = 6; + const centerX = triggerRect.left + triggerRect.width / 2; + const centerY = triggerRect.top + triggerRect.height / 2; + + switch (actualPlacement) { + case "bottom": + return { + position: "fixed", + top: triggerRect.bottom + gap, + left: centerX, + transform: "translateX(-50%)", + }; + case "left": + return { + position: "fixed", + top: centerY, + left: triggerRect.left - gap, + transform: "translate(-100%, -50%)", + }; + case "right": + return { + position: "fixed", + top: centerY, + left: triggerRect.right + gap, + transform: "translateY(-50%)", + }; + default: + return { + position: "fixed", + top: triggerRect.top - gap, + left: centerX, + transform: "translate(-50%, -100%)", + }; + } + }; + + const bubble = isVisible ? ( + + ) : null; + + return ( + + + {bubble && createPortal(bubble, document.body)} + + ); +}; + +export default HelperTooltip; diff --git a/src/components/navbar/index.tsx b/src/components/navbar/index.tsx index aa6b0240..61c3d709 100644 --- a/src/components/navbar/index.tsx +++ b/src/components/navbar/index.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { useSettings } from "../../context/SettingsContext"; +import { useNotify } from "../../hooks/useNotify"; import { useSearch } from "../../hooks/useSearch"; import NavbarLogo from "./NavbarLogo"; import { NetworkBlockIndicator } from "./NetworkBlockIndicator"; @@ -13,7 +14,9 @@ const Navbar = () => { const location = useLocation(); const { searchTerm, setSearchTerm, isResolving, error, clearError, handleSearch, networkId } = useSearch(); - const { isDarkMode, toggleTheme, isSuperUser, toggleSuperUserMode } = useSettings(); + const { isDarkMode, toggleTheme, isSuperUser, toggleSuperUserMode, settings, updateSettings } = + useSettings(); + const notify = useNotify(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); // Check if we should show the search box (on any network page including home) @@ -42,6 +45,30 @@ const Navbar = () => { }; }, [isMobileMenuOpen]); + const knowledgeLevel = settings.knowledgeLevel ?? "beginner"; + const tooltipsEnabled = settings.showHelperTooltips !== false; + + const cycleKnowledgeLevel = () => { + const levels = ["beginner", "intermediate", "advanced"] as const; + const currentIndex = levels.indexOf(knowledgeLevel); + const nextLevel = levels[(currentIndex + 1) % levels.length] ?? "beginner"; + updateSettings({ knowledgeLevel: nextLevel }); + const levelKey = + nextLevel === "beginner" + ? "nav.tooltipsLevelBeginner" + : nextLevel === "intermediate" + ? "nav.tooltipsLevelIntermediate" + : "nav.tooltipsLevelAdvanced"; + notify.success(t("nav.tooltipsSwitched", { level: t(levelKey) }), 2000); + }; + + const knowledgeLevelLabel = + knowledgeLevel === "beginner" + ? t("nav.tooltipsBeginner") + : knowledgeLevel === "intermediate" + ? t("nav.tooltipsIntermediate") + : t("nav.tooltipsAdvanced"); + const goToSettings = () => { navigate("/settings"); }; @@ -137,6 +164,40 @@ const Navbar = () => { )} + {tooltipsEnabled && ( +
  • + +
  • + )}
  • + {/* Tooltip Level toggle */} + {tooltipsEnabled && ( + + )} + {/* Super User Mode toggle */}