2026-02-06 23:01:30 -05:00

156 lines
4.2 KiB
TypeScript

'use client';
import React, { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LogLevel = 'info' | 'success' | 'warning' | 'error';
export interface LogEntry {
timestamp: string;
message: string;
level: LogLevel;
}
export interface DeployLogViewerProps {
logs: LogEntry[];
maxHeight?: string;
className?: string;
}
// ---------------------------------------------------------------------------
// Level colors
// ---------------------------------------------------------------------------
const levelStyles: Record<LogLevel, string> = {
info: 'text-gray-400',
success: 'text-emerald-400',
warning: 'text-amber-400',
error: 'text-red-400',
};
const levelBadge: Record<LogLevel, string> = {
info: 'text-gray-500',
success: 'text-emerald-500',
warning: 'text-amber-500',
error: 'text-red-500',
};
const levelPrefix: Record<LogLevel, string> = {
info: '○',
success: '✓',
warning: '⚠',
error: '✗',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function DeployLogViewer({
logs,
maxHeight = '320px',
className,
}: DeployLogViewerProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom on new logs
useEffect(() => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs.length]);
const formatTime = (ts: string) => {
try {
const d = new Date(ts);
return d.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return '--:--:--';
}
};
return (
<div
className={cn(
'relative rounded-xl border border-gray-800 bg-gray-900 overflow-hidden',
className,
)}
>
{/* Header */}
<div className="flex items-center gap-2 border-b border-gray-800 px-4 py-2">
<div className="flex gap-1.5">
<div className="h-3 w-3 rounded-full bg-red-500/70" />
<div className="h-3 w-3 rounded-full bg-amber-500/70" />
<div className="h-3 w-3 rounded-full bg-emerald-500/70" />
</div>
<span className="ml-2 text-xs font-medium text-gray-500">
Deploy Log
</span>
<span className="ml-auto text-xs text-gray-600">
{logs.length} entries
</span>
</div>
{/* Log content */}
<div
ref={containerRef}
className="overflow-y-auto p-4 font-mono text-sm leading-relaxed"
style={{ maxHeight }}
>
{logs.length === 0 && (
<div className="flex items-center gap-2 text-gray-600">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-gray-600" />
Waiting for deploy to start
</div>
)}
{logs.map((entry, i) => (
<div
key={i}
className={cn(
'flex gap-3 py-0.5 transition-opacity duration-300',
i === logs.length - 1 ? 'opacity-100' : 'opacity-90',
)}
>
{/* Timestamp */}
<span className="shrink-0 text-gray-600 select-none">
{formatTime(entry.timestamp)}
</span>
{/* Level indicator */}
<span
className={cn('shrink-0 w-4 text-center', levelBadge[entry.level])}
>
{levelPrefix[entry.level]}
</span>
{/* Message */}
<span className={cn(levelStyles[entry.level])}>
{entry.message}
</span>
</div>
))}
{/* Cursor blink at bottom */}
{logs.length > 0 && (
<div className="mt-1 flex items-center gap-1 text-gray-600">
<span className="inline-block h-3.5 w-1.5 animate-pulse bg-indigo-500/70" />
</div>
)}
<div ref={bottomRef} />
</div>
</div>
);
}