Skip to content
Open
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 admin/app/api-explorer/components/HistoryPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';

import React from 'react';
import { useApiExplorer } from './context';
import type { HistoryEntry, HttpMethod } from './types';
import { useApiExplorer } from '../context';
import type { HistoryEntry, HttpMethod } from '../types';

const METHOD_COLORS: Record<HttpMethod, string> = {
GET: 'text-green-400',
Expand Down
4 changes: 2 additions & 2 deletions admin/app/api-explorer/components/RequestBuilder.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';

import React, { useEffect } from 'react';
import { useApiExplorer } from './context';
import type { HttpMethod, Parameter } from './types';
import { useApiExplorer } from '../context';
import type { HttpMethod, Parameter } from '../types';

const HTTP_METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];

Expand Down
2 changes: 1 addition & 1 deletion admin/app/api-explorer/components/ResponseViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import React, { useState } from 'react';
import { useApiExplorer } from './context';
import { useApiExplorer } from '../context';

type TabType = 'body' | 'headers';

Expand Down
4 changes: 2 additions & 2 deletions admin/app/api-explorer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';

import React from 'react';
import { useApiExplorer } from './context';
import type { Endpoint, HttpMethod } from './types';
import { useApiExplorer } from '../context';
import type { Endpoint, HttpMethod } from '../types';

const METHOD_COLORS: Record<HttpMethod, string> = {
GET: 'bg-green-500/20 text-green-400 border-green-500/30',
Expand Down
97 changes: 97 additions & 0 deletions admin/app/code-display-demo/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';

import { CodeDisplay } from '@/app/components/CodeDisplay';

const JS_CODE = `function greet(name) {
const message = \`Hello, \${name}!\`;
console.log(message);
return message;
}

greet('SoroScan');`;

const TS_CODE = `interface Transaction {
id: string;
amount: number;
status: 'pending' | 'confirmed' | 'failed';
}

async function fetchTransaction(id: string): Promise<Transaction> {
const res = await fetch(\`/api/transactions/\${id}\`);
if (!res.ok) throw new Error('Not found');
return res.json();
}`;

const PYTHON_CODE = `def decode_event(payload: dict) -> dict:
"""Decode a raw contract event payload."""
event_type = payload.get("type", "unknown")
data = payload.get("data", {})
return {"event": event_type, "decoded": data}`;

const JSON_CODE = `{
"contract": "GABC1234XYZ",
"event": "Transfer",
"payload": {
"from": "GADDR1",
"to": "GADDR2",
"amount": "1000000"
},
"ledger": 82109334,
"timestamp": "2026-04-26T10:00:00Z"
}`;

const BASH_CODE = `#!/bin/bash
# Deploy SoroScan backend
docker build -t soroscan-backend .
docker push registry.example.com/soroscan-backend:latest
kubectl rollout restart deployment/soroscan-backend`;

const LONG_CODE = `const result = await client.query({ query: GET_CONTRACT_EVENTS, variables: { contractId: "GABC1234XYZ", limit: 100, offset: 0, filter: { eventType: "Transfer", fromLedger: 82000000 } } });`;

export default function CodeDisplayDemo() {
return (
<main className="min-h-screen p-8 font-mono" style={{ background: '#0a0f0a', color: '#e0ffe0' }}>
<h1 className="text-2xl tracking-widest uppercase mb-1" style={{ color: '#00ff88' }}>
CodeDisplay
</h1>
<p className="text-xs tracking-wider mb-10" style={{ color: '#4a7a4a' }}>
Component preview — syntax highlighting · copy · line numbers · dark terminal theme
</p>

<section className="mb-8">
<p className="text-xs tracking-widest uppercase mb-3" style={{ color: '#4a7a4a' }}>JavaScript</p>
<CodeDisplay code={JS_CODE} language="javascript" label="greet.js" showLineNumbers />
</section>

<section className="mb-8">
<p className="text-xs tracking-widest uppercase mb-3" style={{ color: '#4a7a4a' }}>TypeScript</p>
<CodeDisplay code={TS_CODE} language="typescript" label="transaction.ts" showLineNumbers />
</section>

<section className="mb-8">
<p className="text-xs tracking-widest uppercase mb-3" style={{ color: '#4a7a4a' }}>Python</p>
<CodeDisplay code={PYTHON_CODE} language="python" label="decoder.py" />
</section>

<section className="mb-8">
<p className="text-xs tracking-widest uppercase mb-3" style={{ color: '#4a7a4a' }}>JSON</p>
<CodeDisplay code={JSON_CODE} language="json" label="event.json" showLineNumbers />
</section>

<section className="mb-8">
<p className="text-xs tracking-widest uppercase mb-3" style={{ color: '#4a7a4a' }}>Bash</p>
<CodeDisplay code={BASH_CODE} language="bash" label="deploy.sh" />
</section>

<section className="mb-8">
<p className="text-xs tracking-widest uppercase mb-3" style={{ color: '#4a7a4a' }}>Long line — horizontal scroll</p>
<CodeDisplay code={LONG_CODE} language="javascript" label="long-line.js" />
</section>

<section className="mb-8">
<p className="text-xs tracking-widest uppercase mb-3" style={{ color: '#4a7a4a' }}>No label · no line numbers</p>
<CodeDisplay code={JS_CODE} language="javascript" />
</section>
</main>
);
}
86 changes: 86 additions & 0 deletions admin/app/components/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use client';

import React, { useState } from 'react';

export type AvatarSize = 'sm' | 'md' | 'lg';

export interface AvatarProps {
/** Image URL to display */
src?: string;
/** Full name used for initials fallback and tooltip */
name: string;
/** Size variant */
size?: AvatarSize;
/** Optional background color override */
color?: string;
}

const SIZE_CLASSES: Record<AvatarSize, string> = {
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-14 h-14 text-lg',
};

/** Generates a deterministic HSL background color from a string */
function colorFromName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 55%, 40%)`;
}

/** Derives up to 2 uppercase initials from a name string */
export function getInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

export function Avatar({ src, name, size = 'md', color }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const showImage = Boolean(src) && !imgError;
const bg = color ?? colorFromName(name);
const sizeClass = SIZE_CLASSES[size];
const initials = getInitials(name);

return (
<div className="relative inline-flex group">
<div
className={`${sizeClass} rounded-sm overflow-hidden flex items-center justify-center font-bold select-none focus:outline-none focus:ring-1 focus:ring-[#00ff88]`}
style={showImage ? undefined : { backgroundColor: bg, border: '1px solid #00ff88', color: '#00ff88' }}
role="img"
aria-label={name}
tabIndex={0}
>
{showImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={name}
className="w-full h-full object-cover"
onError={() => setImgError(true)}
/>
) : (
<span aria-hidden="true" className="font-mono tracking-wider">{initials}</span>
)}
</div>

{/* Tooltip */}
<span
role="tooltip"
className="
pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2
whitespace-nowrap rounded px-2 py-1 text-xs font-mono shadow-lg
opacity-0 group-hover:opacity-100 group-focus-within:opacity-100
transition-opacity duration-150 z-50
"
style={{ background: '#0d1a0d', border: '1px solid #00ff88', color: '#00ff88' }}
>
{name}
</span>
</div>
);
}
112 changes: 112 additions & 0 deletions admin/app/components/Avatar/__tests__/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Avatar } from '../Avatar';

describe('Avatar', () => {
describe('Image rendering', () => {
it('renders an img element when a valid src is provided', () => {
render(<Avatar src="https://example.com/avatar.jpg" name="Jane Doe" />);
const imgs = screen.getAllByRole('img', { name: 'Jane Doe' });
const imgEl = imgs.find((el) => el.tagName === 'IMG');
expect(imgEl).toBeInTheDocument();
expect(imgEl).toHaveAttribute('src', 'https://example.com/avatar.jpg');
});

it('does not render initials when image src is provided', () => {
render(<Avatar src="https://example.com/avatar.jpg" name="Jane Doe" />);
expect(screen.queryByText('JD')).not.toBeInTheDocument();
});
});

describe('Initials fallback', () => {
it('renders initials when no src is provided', () => {
render(<Avatar name="Jane Doe" />);
expect(screen.getByText('JD')).toBeInTheDocument();
});

it('renders initials in uppercase', () => {
render(<Avatar name="john smith" />);
expect(screen.getByText('JS')).toBeInTheDocument();
});

it('renders single initial for a single-word name', () => {
render(<Avatar name="Madonna" />);
expect(screen.getByText('M')).toBeInTheDocument();
});

it('renders "?" for an empty name', () => {
render(<Avatar name="" />);
expect(screen.getByText('?')).toBeInTheDocument();
});

it('renders initials when image fails to load', () => {
render(<Avatar src="https://broken.url/img.jpg" name="Jane Doe" />);
const imgs = screen.getAllByRole('img', { name: 'Jane Doe' });
const imgEl = imgs.find((el) => el.tagName === 'IMG')!;
fireEvent.error(imgEl);
expect(screen.getByText('JD')).toBeInTheDocument();
});
});

describe('Size variants', () => {
it.each([
['sm', 'w-8 h-8 text-xs'],
['md', 'w-10 h-10 text-sm'],
['lg', 'w-14 h-14 text-lg'],
] as const)('applies correct classes for size "%s"', (size, expectedClasses) => {
render(<Avatar name="Test User" size={size} />);
const avatar = screen.getByRole('img', { name: 'Test User' });
expectedClasses.split(' ').forEach((cls) => {
expect(avatar).toHaveClass(cls);
});
});

it('defaults to md size when no size prop is given', () => {
render(<Avatar name="Test User" />);
const avatar = screen.getByRole('img', { name: 'Test User' });
expect(avatar).toHaveClass('w-10', 'h-10', 'text-sm');
});
});

describe('Background color', () => {
it('applies a custom color when provided', () => {
render(<Avatar name="Test User" color="#ff0000" />);
const avatar = screen.getByRole('img', { name: 'Test User' });
expect(avatar).toHaveStyle({ backgroundColor: '#ff0000' });
});

it('applies a generated background color when no color prop is given', () => {
render(<Avatar name="Test User" />);
const avatar = screen.getByRole('img', { name: 'Test User' });
expect(avatar.getAttribute('style')).toMatch(/background-color/);
});
});

describe('Tooltip', () => {
it('renders a tooltip with the user name', () => {
render(<Avatar name="Jane Doe" />);
expect(screen.getByRole('tooltip')).toHaveTextContent('Jane Doe');
});

it('tooltip is accessible via keyboard focus', () => {
render(<Avatar name="Jane Doe" />);
const avatar = screen.getByRole('img', { name: 'Jane Doe' });
avatar.focus();
expect(screen.getByRole('tooltip')).toBeInTheDocument();
});
});

describe('Accessibility', () => {
it('has aria-label equal to the name prop', () => {
render(<Avatar name="Jane Doe" />);
expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('aria-label', 'Jane Doe');
});

it('is focusable via keyboard', () => {
render(<Avatar name="Jane Doe" />);
const avatar = screen.getByRole('img', { name: 'Jane Doe' });
expect(avatar).toHaveAttribute('tabIndex', '0');
});
});
});
2 changes: 2 additions & 0 deletions admin/app/components/Avatar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Avatar } from './Avatar';
export type { AvatarProps, AvatarSize } from './Avatar';
Loading
Loading