diff --git a/dashboard/src/components/EventExplorerCard.tsx b/dashboard/src/components/EventExplorerCard.tsx index 160ea81..cd905f6 100644 --- a/dashboard/src/components/EventExplorerCard.tsx +++ b/dashboard/src/components/EventExplorerCard.tsx @@ -34,6 +34,10 @@ interface EventExplorerCardProps { event: BlockchainEvent; onCopyContract: (contractAddress: string) => void; isCopied: boolean; + onSelect?: (event: BlockchainEvent) => void; +} + +export function EventExplorerCard({ event, onCopyContract, isCopied, onSelect }: EventExplorerCardProps) { contractStatuses: ContractStatus[]; } @@ -50,12 +54,40 @@ export function EventExplorerCard({ const kindLabel = getEventKindLabel(event.type); return ( -
+
onSelect(event) : undefined} + onKeyDown={ + onSelect + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(event); + } + } + : undefined + } + aria-label={onSelect ? `View details for ${label} notification` : undefined} + >

{shortenAddress(event.contractAddress)}

+
+ + + {copyMessage && ( +
+ {copyMessage} +
+ )} + +
+

Sender Details

+
+ Address + {shorten(sender.address)} + +
+ {sender.metadata && ( +
+ {Object.entries(sender.metadata).map(([key, value]) => ( +
+
{key}
+
{value}
+
+ ))} +
+ )} +
+ +
+

Blockchain Context

+
+ Ledger + {notification.ledger.toLocaleString()} +
+
+ Event ID + {shorten(notification.eventId, 10, 6)} + +
+
+ Tx Hash + + {notification.txHash ? shorten(notification.txHash) : '—'} + + {notification.txHash && ( + + )} +
+
+ Observed + {formatTimestamp(notification.receivedAt)} +
+
+ +
+

Notification Status History

+ + {fetchState.status === 'loading' && ( +

+ Loading details… +

+ )} + + {fetchState.status === 'error' && ( +

+ Failed to load details: {fetchState.message} +

+ )} + + {fetchState.status === 'success' && statusHistory.length === 0 && ( +

No status history available.

+ )} + + {fetchState.status === 'success' && statusHistory.length > 0 && ( +
    + {statusHistory + .slice() + .sort((a, b) => a.timestampMs - b.timestampMs) + .map((entry) => ( +
  1. +
  2. + ))} +
+ )} +
+ +
+ ); +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 4968c5d..14e4729 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -460,6 +460,49 @@ body { padding: 24px 0 12px; } +.drawer { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + grid-template-columns: 1fr auto; +} + +.drawer__backdrop { + grid-column: 1 / -1; + grid-row: 1 / -1; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); +} + +.drawer__panel { + grid-column: 2; + grid-row: 1; + width: min(92vw, 420px); + height: 100%; + background: #0b0d12; + border-left: 1px solid rgba(255, 255, 255, 0.08); + padding: 18px 16px 24px; + overflow: auto; + box-shadow: -12px 0 40px rgba(0, 0, 0, 0.55); + animation: drawer-slide-in 160ms ease-out; +} + +@keyframes drawer-slide-in { + from { transform: translateX(12px); opacity: 0.75; } + to { transform: translateX(0); opacity: 1; } +} + +.drawer__header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.drawer__eyebrow { .indexing-health { border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 16px; @@ -482,6 +525,179 @@ body { font-size: 0.75rem; letter-spacing: 0.08em; text-transform: uppercase; + color: #60a5fa; +} + +.drawer__title { + margin: 0; + font-size: 1.1rem; +} + +.drawer__close { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: inherit; + border-radius: 10px; + width: 38px; + height: 38px; + cursor: pointer; + font-size: 1.35rem; + line-height: 1; +} + +.drawer__close:hover, +.drawer__close:focus-visible { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.1); + outline: none; +} + +.drawer__toast { + margin-top: 12px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + background: rgba(255, 255, 255, 0.06); + color: #e2e8f0; + font-size: 0.9rem; +} + +.drawer__section { + margin-top: 18px; + display: grid; + gap: 10px; +} + +.drawer__section-title { + margin: 0; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9aa0a6; +} + +.drawer__row { + display: grid; + grid-template-columns: 90px 1fr auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); +} + +.drawer__label { + font-size: 0.78rem; + color: #9aa0a6; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.drawer__value { + font-family: 'Courier New', Courier, monospace; + font-size: 0.9rem; + color: #e2e8f0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.drawer__action { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + padding: 8px 10px; + font-weight: 600; + cursor: pointer; +} + +.drawer__action:hover, +.drawer__action:focus-visible { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.1); + outline: none; +} + +.drawer__meta { + margin: 0; + display: grid; + gap: 8px; +} + +.drawer__meta-row { + display: grid; + grid-template-columns: 90px 1fr; + gap: 10px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); +} + +.drawer__meta-row dt { + margin: 0; + font-size: 0.78rem; + color: #9aa0a6; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.drawer__meta-row dd { + margin: 0; + color: #e2e8f0; + font-size: 0.9rem; +} + +.drawer__timeline { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 12px; +} + +.drawer__timeline-item { + display: grid; + grid-template-columns: 12px 1fr; + gap: 12px; + align-items: flex-start; +} + +.drawer__timeline-dot { + margin-top: 6px; + width: 10px; + height: 10px; + border-radius: 50%; + background: rgba(96, 165, 250, 0.9); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18); +} + +.drawer__timeline-body { + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.02); +} + +.drawer__timeline-title { + font-weight: 650; + margin-bottom: 4px; +} + +.drawer__timeline-time { + color: #9aa0a6; + font-size: 0.85rem; +} + +.drawer__timeline-detail { + margin-top: 6px; + color: #cbd5e1; + font-size: 0.9rem; +} + +.drawer__muted { color: #a78bfa; } @@ -565,12 +781,34 @@ body { font-size: 0.9rem; } +.drawer__error { .indexing-health__error { margin: 0; color: #f87171; font-size: 0.9rem; } +@media (max-width: 600px) { + .drawer { + grid-template-columns: 1fr; + } + + .drawer__panel { + grid-column: 1; + width: 100%; + border-left: none; + border-top: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px 16px 0 0; + padding-top: 16px; + animation: drawer-slide-up 180ms ease-out; + } + + @keyframes drawer-slide-up { + from { transform: translateY(16px); opacity: 0.75; } + to { transform: translateY(0); opacity: 1; } + } +} + .event-explorer__eyebrow { margin: 0 0 10px; font-size: 0.85rem; diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 3a5eed3..ad6276d 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -5,6 +5,7 @@ import { WalletConnectButton } from '../components/WalletConnectButton'; import { EventExplorerTable } from '../components/EventExplorerTable'; import { EventExplorerSkeleton } from '../components/EventExplorerSkeleton'; import { PaginationControls } from '../components/PaginationControls'; +import { NotificationDetailsDrawer } from '../components/NotificationDetailsDrawer'; import { IndexingHealthPanel } from '../components/IndexingHealthPanel'; import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors'; import { useEventStore } from '../store/eventStore'; @@ -12,6 +13,7 @@ import { fetchEvents, fetchStatus, type ContractStatus } from '../services/event import { resolveIndexingHealthUrl } from '../services/indexingHealthApi'; import { generateMockEvents } from '../utils/eventData'; import { restoreWalletSession } from '../services/wallet'; +import type { BlockchainEvent } from '../types/event'; import { useWalletAccountSync } from '../hooks/useWalletAccountSync'; const DEFAULT_EVENT_COUNT = 5000; @@ -37,6 +39,7 @@ export function EventExplorerPage() { const initialSearch = typeof window !== 'undefined' ? window.location.search : ''; const [page, setPage] = useState(() => parsePageParam(initialSearch)); const [limit, setLimit] = useState(() => parseLimitParam(initialSearch)); + const [selectedNotification, setSelectedNotification] = useState(null); const [contractStatuses, setContractStatuses] = useState([]); const setEvents = useEventStore((state) => state.setEvents); @@ -175,6 +178,14 @@ export function EventExplorerPage() { } }, [setError, setEvents, setLoading]); + const handleSelectEvent = useCallback((event: BlockchainEvent) => { + setSelectedNotification(event); + }, []); + + const handleCloseDrawer = useCallback(() => { + setSelectedNotification(null); + }, []); + return (
@@ -236,6 +247,7 @@ export function EventExplorerPage() { {isLoading ? ( ) : currentPageEvents.length > 0 ? ( + ) : (
@@ -255,6 +267,12 @@ export function EventExplorerPage() { onPageChange={setPage} onLimitChange={setLimit} /> + +
); } diff --git a/dashboard/src/utils/clipboard.ts b/dashboard/src/utils/clipboard.ts new file mode 100644 index 0000000..1e53658 --- /dev/null +++ b/dashboard/src/utils/clipboard.ts @@ -0,0 +1,27 @@ +export async function copyTextToClipboard(text: string): Promise { + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + + if (typeof document === 'undefined') { + return false; + } + + const fallback = document.createElement('textarea'); + fallback.value = text; + fallback.setAttribute('readonly', ''); + fallback.style.position = 'absolute'; + fallback.style.left = '-9999px'; + document.body.appendChild(fallback); + fallback.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(fallback); + return successful; + } catch { + return false; + } +} +