Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { EventsPage } from './pages/EventsPage';
import { EventExplorerPage } from './pages/EventExplorerPage';

export function App() {
return (
<div className="app">
<EventsPage />
<EventExplorerPage />
</div>
);
}
86 changes: 86 additions & 0 deletions dashboard/src/components/EventExplorerCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { BlockchainEvent } from '../types/event';
import { formatTimestamp } from '../utils/formatTime';

const EVENT_KIND_STYLES: Record<string, string> = {
contract: 'event-explorer__badge--blue',
system: 'event-explorer__badge--purple',
debug: 'event-explorer__badge--default',
};

const EVENT_KIND_LABELS: Record<string, string> = {
contract: 'Contract',
system: 'System',
debug: 'Debug',
};

function shortenAddress(address: string) {
if (address.length <= 14) {
return address;
}

return `${address.slice(0, 6)}...${address.slice(-4)}`;
}

function getEventKindClass(type: string) {
return EVENT_KIND_STYLES[type.toLowerCase()] ?? EVENT_KIND_STYLES.debug;
}

function getEventKindLabel(type: string) {
return EVENT_KIND_LABELS[type.toLowerCase()] ?? 'Unknown';
}

interface EventExplorerCardProps {
event: BlockchainEvent;
onCopyContract: (contractAddress: string) => void;
isCopied: boolean;
}

export function EventExplorerCard({ event, onCopyContract, isCopied }: EventExplorerCardProps) {
const label = event.eventName ?? event.type;
const badgeClass = getEventKindClass(event.type);
const kindLabel = getEventKindLabel(event.type);

return (
<article className="event-explorer__row" role="row" data-event-id={event.eventId}>
<div className="event-explorer__cell" data-label="Contract" role="cell">
<div>
<p className="event-explorer__contract" title={event.contractAddress}>
{shortenAddress(event.contractAddress)}
</p>
<button
type="button"
className="event-explorer__copy-button"
onClick={() => onCopyContract(event.contractAddress)}
aria-label={`Copy contract address ${event.contractAddress}`}
>
{isCopied ? 'Copied' : 'Copy'}
</button>
</div>
</div>

<div className="event-explorer__cell" data-label="Event" role="cell">
<p className="event-explorer__event-name">{label}</p>
</div>

<div className="event-explorer__cell" data-label="Kind" role="cell">
<span className={`event-explorer__badge ${badgeClass}`}>{kindLabel}</span>
</div>

<div className="event-explorer__cell" data-label="Received" role="cell">
<time dateTime={new Date(event.receivedAt).toISOString()}>
{formatTimestamp(event.receivedAt)}
</time>
</div>

<div className="event-explorer__cell" data-label="Ledger" role="cell">
<span>{event.ledger.toLocaleString()}</span>
</div>

<div className="event-explorer__cell" data-label="Transaction" role="cell">
<span title={event.txHash ?? 'No transaction hash'}>
{event.txHash ? shortenAddress(event.txHash) : '—'}
</span>
</div>
</article>
);
}
22 changes: 22 additions & 0 deletions dashboard/src/components/EventExplorerSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
interface EventExplorerSkeletonProps {
rows?: number;
}

export function EventExplorerSkeleton({ rows = 6 }: EventExplorerSkeletonProps) {
return (
<section className="event-explorer__table-wrapper event-explorer__skeleton" aria-busy="true">
<div className="event-explorer__table-body">
{Array.from({ length: rows }).map((_, rowIndex) => (
<article key={rowIndex} className="event-explorer__row event-explorer__row--loading">
<div className="event-explorer__cell" />
<div className="event-explorer__cell" />
<div className="event-explorer__cell" />
<div className="event-explorer__cell" />
<div className="event-explorer__cell" />
<div className="event-explorer__cell" />
</article>
))}
</div>
</section>
);
}
66 changes: 66 additions & 0 deletions dashboard/src/components/EventExplorerTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useState } from 'react';
import type { BlockchainEvent } from '../types/event';
import { EventExplorerCard } from './EventExplorerCard';

interface EventExplorerTableProps {
events: BlockchainEvent[];
}

export function EventExplorerTable({ events }: EventExplorerTableProps) {
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);

async function syncCopyText(text: string) {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text);
}

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);

if (!successful) {
throw new Error('Clipboard copy failed.');
}
}

const handleCopyContract = async (address: string) => {
try {
await syncCopyText(address);
setCopiedAddress(address);
window.setTimeout(() => setCopiedAddress(null), 1800);
} catch {
setCopiedAddress(null);
}
};

return (
<section className="event-explorer__table-wrapper">
<div className="event-explorer__table-header" role="rowgroup">
<div>Contract</div>
<div>Event</div>
<div>Kind</div>
<div>Received</div>
<div>Ledger</div>
<div>Transaction</div>
</div>

<div className="event-explorer__table-body" role="rowgroup">
{events.map((event) => (
<EventExplorerCard
key={event.eventId}
event={event}
onCopyContract={handleCopyContract}
isCopied={copiedAddress === event.contractAddress}
/>
))}
</div>
</section>
);
}
74 changes: 74 additions & 0 deletions dashboard/src/components/PaginationControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
interface PaginationControlsProps {
page: number;
pageCount: number;
limit: number;
totalCount: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
}

const LIMIT_OPTIONS = [8, 12, 20, 40];

export function PaginationControls({
page,
pageCount,
limit,
totalCount,
onPageChange,
onLimitChange,
}: PaginationControlsProps) {
const handlePrevious = () => {
onPageChange(Math.max(1, page - 1));
};

const handleNext = () => {
onPageChange(Math.min(pageCount, page + 1));
};

return (
<section className="pagination-controls" aria-label="Pagination controls">
<div className="pagination-controls__summary">
<span>
Page {page} of {pageCount}
</span>
<span>{totalCount.toLocaleString()} total events</span>
</div>

<div className="pagination-controls__actions">
<button
type="button"
className="pagination-controls__button"
onClick={handlePrevious}
disabled={page <= 1}
>
Previous
</button>

<label className="pagination-controls__label" htmlFor="items-per-page">
Items per page
</label>
<select
id="items-per-page"
className="pagination-controls__select"
value={limit}
onChange={(event) => onLimitChange(Number(event.target.value))}
>
{LIMIT_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>

<button
type="button"
className="pagination-controls__button"
onClick={handleNext}
disabled={page >= pageCount}
>
Next
</button>
</div>
</section>
);
}
Loading
Loading