feat: redesign PortCard with broadcast panel aesthetic

This commit is contained in:
Zac Gaetano 2026-04-14 09:44:51 -04:00
parent e2e569d12e
commit b3685565ea

View file

@ -11,107 +11,159 @@ interface PortCardProps {
} }
function formatUptime(seconds: number): string { function formatUptime(seconds: number): string {
const hours = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
} }
function truncateFilePath(path: string, maxLength: number = 50): string { function truncateFilePath(path: string, maxLength = 38): string {
if (path.length <= maxLength) { if (!path) return '—';
return path; return path.length <= maxLength ? path : '...' + path.slice(-(maxLength - 3));
}
return '...' + path.slice(-(maxLength - 3));
} }
export function PortCard({ export function PortCard({
port, port, isSelected, onSelect, onStartRecording, onStopRecording, onOpenConfig,
isSelected,
onSelect,
onStartRecording,
onStopRecording,
onOpenConfig,
}: PortCardProps) { }: PortCardProps) {
const recordingBadgeClass = port.is_recording const isRec = port.is_recording;
? 'bg-red-500 text-white text-xs px-2 py-1 rounded'
: 'bg-gray-500 text-white text-xs px-2 py-1 rounded';
const recordingStatus = port.is_recording ? 'RECORDING' : 'IDLE'; const cardStyle: React.CSSProperties = {
background: 'var(--bg-card)',
const cardClass = `bg-gray-800 border border-gray-700 rounded-lg p-4 cursor-pointer hover:border-blue-500 transition-colors ${ border: `1px solid ${isRec ? 'var(--rec-red)' : isSelected ? 'var(--dragon-blue)' : 'var(--border)'}`,
isSelected ? 'border-blue-500 ring-2 ring-blue-500' : '' borderRadius: 4,
}`; overflow: 'hidden',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
boxShadow: isRec
? '0 0 0 1px rgba(255,32,32,0.4), 0 4px 20px rgba(255,32,32,0.2)'
: isSelected
? '0 0 0 1px var(--dragon-blue), 0 6px 30px rgba(26,58,255,0.25)'
: 'none',
};
return ( return (
<div className={cardClass} onClick={() => onSelect(port.port_index)}> <div style={cardStyle} onClick={() => onSelect(port.port_index)}>
{/* Header: Port number and recording status badge */}
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-white">Port {port.port_index}</h3>
<span className={recordingBadgeClass}>{recordingStatus}</span>
</div>
{/* Metrics row */} {/* Recording bar */}
<div className="grid grid-cols-2 gap-2 mb-3 text-sm text-gray-300"> <div style={{
<div> height: 3,
<span className="text-gray-400">Frames:</span> {port.frame_count} background: isRec ? 'var(--rec-red)' : 'var(--bg-panel)',
</div> transition: 'background 0.2s',
<div> animation: isRec ? 'recBarPulse 1s ease-in-out infinite' : 'none',
<span className="text-gray-400">FPS:</span> {port.fps.toFixed(2)} }} />
</div>
<div>
<span className="text-gray-400">Bitrate:</span> {port.bitrate_mbps.toFixed(2)} Mbps
</div>
<div>
<span className="text-gray-400">Uptime:</span> {formatUptime(port.uptime_seconds)}
</div>
</div>
{/* Current file path */} <div style={{ padding: 16 }}>
<div className="mb-3 text-sm">
<span className="text-gray-400">File:</span> {/* Header row */}
<div className="text-gray-300 truncate font-mono text-xs mt-1"> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 22, fontWeight: 700, letterSpacing: '0.04em', lineHeight: 1, color: 'var(--text-primary)' }}>
PORT {port.port_index}
<span style={{ fontSize: 12, fontWeight: 400, letterSpacing: '0.2em', color: 'var(--text-secondary)', marginLeft: 6 }}>SDI</span>
</div>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.15em',
padding: '3px 8px', borderRadius: 2, textTransform: 'uppercase' as const,
background: isRec ? 'rgba(255,32,32,0.15)' : 'rgba(61,82,112,0.3)',
color: isRec ? 'var(--rec-red)' : 'var(--text-muted)',
border: `1px solid ${isRec ? 'rgba(255,32,32,0.4)' : 'var(--border)'}`,
animation: isRec ? 'badgePulse 1s ease-in-out infinite' : 'none',
}}>
{isRec ? 'REC' : 'IDLE'}
</span>
</div>
{/* Metrics */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 12px', marginBottom: 12 }}>
{[
{ label: 'FPS', value: port.fps.toFixed(2), highlight: true },
{ label: 'Bitrate', value: `${port.bitrate_mbps.toFixed(1)} Mbps`, highlight: false },
{ label: 'Frames', value: port.frame_count.toLocaleString(), highlight: false },
{ label: 'Uptime', value: formatUptime(port.uptime_seconds), highlight: false },
].map(({ label, value, highlight }) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 9, fontWeight: 600, letterSpacing: '0.25em', textTransform: 'uppercase' as const, color: 'var(--text-muted)' }}>
{label}
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 14, color: highlight ? 'var(--accent-amber)' : 'var(--text-mono)', letterSpacing: '0.03em' }}>
{value}
</span>
</div>
))}
</div>
{/* File path */}
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
color: isRec ? 'rgba(255,120,120,0.8)' : 'var(--text-secondary)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
marginBottom: 14, padding: '6px 8px',
background: 'rgba(0,0,0,0.3)', borderRadius: 2,
borderLeft: `2px solid ${isRec ? 'var(--rec-red)' : 'var(--border)'}`,
}}>
{truncateFilePath(port.current_file)} {truncateFilePath(port.current_file)}
</div> </div>
{/* Codec */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 14 }}>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 9, fontWeight: 600, letterSpacing: '0.25em', textTransform: 'uppercase' as const, color: 'var(--text-muted)' }}>Codec</span>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.1em',
padding: '2px 7px', borderRadius: 2,
background: 'rgba(26,58,255,0.15)', color: '#7aa4ff',
border: '1px solid rgba(26,58,255,0.3)',
}}>
{port.codec}
</span>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8 }}>
{isRec ? (
<button
onClick={e => { e.stopPropagation(); onStopRecording(port.port_index); }}
style={{
flex: 1, fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
letterSpacing: '0.1em', textTransform: 'uppercase' as const,
background: 'rgba(255,32,32,0.12)', color: 'var(--rec-red)',
border: '1px solid rgba(255,32,32,0.35)', borderRadius: 3,
padding: '7px 14px', cursor: 'pointer',
}}
> Stop</button>
) : (
<button
onClick={e => { e.stopPropagation(); onStartRecording(port.port_index); }}
style={{
flex: 1, fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
letterSpacing: '0.1em', textTransform: 'uppercase' as const,
background: 'rgba(0,230,118,0.12)', color: 'var(--green-safe)',
border: '1px solid rgba(0,230,118,0.35)', borderRadius: 3,
padding: '7px 14px', cursor: 'pointer',
}}
> Start</button>
)}
<button
onClick={e => { e.stopPropagation(); onOpenConfig(port.port_index); }}
style={{
fontFamily: 'var(--font-ui)', fontSize: 13, background: 'transparent',
color: 'var(--text-secondary)', border: '1px solid var(--border)',
borderRadius: 3, padding: '7px 10px', cursor: 'pointer',
}}
title="Configure"
></button>
</div>
</div> </div>
{/* Codec info */} <style>{`
<div className="mb-4 text-sm text-gray-300"> @keyframes recBarPulse {
<span className="text-gray-400">Codec:</span> {port.codec} 0%, 100% { opacity: 1; }
</div> 50% { opacity: 0.55; }
}
{/* Control buttons */} @keyframes badgePulse {
<div className="flex gap-2"> 0%, 100% { opacity: 1; }
{!port.is_recording ? ( 50% { opacity: 0.65; }
<button }
onClick={(e) => { `}</style>
e.stopPropagation();
onStartRecording(port.port_index);
}}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm transition-colors"
>
Start
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
onStopRecording(port.port_index);
}}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm transition-colors"
>
Stop
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onOpenConfig(port.port_index);
}}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm transition-colors"
>
Config
</button>
</div>
</div> </div>
); );
} }