99 lines
3.4 KiB
TypeScript
99 lines
3.4 KiB
TypeScript
export function Spinner() {
|
|
return (
|
|
<div className="flex items-center justify-center p-12">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function EmptyState({ message = 'No data found' }: { message?: string }) {
|
|
return (
|
|
<div className="flex items-center justify-center p-12 text-gray-500">
|
|
<div className="text-center">
|
|
<svg className="mx-auto h-12 w-12 text-gray-600 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
|
/>
|
|
</svg>
|
|
<p>{message}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function HashBadge({ hash, short = true }: { hash: string; short?: boolean }) {
|
|
const display = short && hash.length > 8 ? hash.slice(0, 8) : hash
|
|
return (
|
|
<span className="inline-block px-2 py-1 bg-gray-800/50 rounded font-mono text-xs text-gray-400">{display}</span>
|
|
)
|
|
}
|
|
|
|
export function Pagination({
|
|
total,
|
|
limit,
|
|
offset,
|
|
onPageChange,
|
|
onLimitChange,
|
|
}: {
|
|
total: number
|
|
limit: number
|
|
offset: number
|
|
onPageChange: (newOffset: number) => void
|
|
onLimitChange: (newLimit: number) => void
|
|
}) {
|
|
const currentPage = Math.floor(offset / limit) + 1
|
|
const totalPages = Math.ceil(total / limit)
|
|
const startItem = total === 0 ? 0 : offset + 1
|
|
const endItem = Math.min(offset + limit, total)
|
|
|
|
const canPrev = offset > 0
|
|
const canNext = offset + limit < total
|
|
|
|
return (
|
|
<div className="flex items-center justify-between border-t border-gray-800 bg-gray-900/50 px-4 py-3 mt-4">
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-sm text-gray-400">
|
|
{startItem}-{endItem} of {total}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-gray-500">Per page:</label>
|
|
<select
|
|
value={limit}
|
|
onChange={(e) => {
|
|
onLimitChange(parseInt(e.target.value, 10))
|
|
onPageChange(0) // reset to first page
|
|
}}
|
|
className="bg-gray-800 text-gray-300 border border-gray-700 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="25">25</option>
|
|
<option value="50">50</option>
|
|
<option value="100">100</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => onPageChange(offset - limit)}
|
|
disabled={!canPrev}
|
|
className="px-3 py-1 bg-gray-800 text-gray-300 rounded border border-gray-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-700 text-sm"
|
|
>
|
|
Previous
|
|
</button>
|
|
<span className="text-sm text-gray-400">
|
|
Page {currentPage} / {totalPages || 1}
|
|
</span>
|
|
<button
|
|
onClick={() => onPageChange(offset + limit)}
|
|
disabled={!canNext}
|
|
className="px-3 py-1 bg-gray-800 text-gray-300 rounded border border-gray-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-700 text-sm"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|