feat: redesign PortCard with broadcast panel aesthetic
This commit is contained in:
parent
e2e569d12e
commit
b3685565ea
1 changed files with 138 additions and 86 deletions
|
|
@ -11,107 +11,159 @@ interface PortCardProps {
|
|||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function truncateFilePath(path: string, maxLength: number = 50): string {
|
||||
if (path.length <= maxLength) {
|
||||
return path;
|
||||
}
|
||||
return '...' + path.slice(-(maxLength - 3));
|
||||
function truncateFilePath(path: string, maxLength = 38): string {
|
||||
if (!path) return '—';
|
||||
return path.length <= maxLength ? path : '...' + path.slice(-(maxLength - 3));
|
||||
}
|
||||
|
||||
export function PortCard({
|
||||
port,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onOpenConfig,
|
||||
port, isSelected, onSelect, onStartRecording, onStopRecording, onOpenConfig,
|
||||
}: PortCardProps) {
|
||||
const recordingBadgeClass = 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 isRec = port.is_recording;
|
||||
|
||||
const recordingStatus = port.is_recording ? 'RECORDING' : 'IDLE';
|
||||
|
||||
const cardClass = `bg-gray-800 border border-gray-700 rounded-lg p-4 cursor-pointer hover:border-blue-500 transition-colors ${
|
||||
isSelected ? 'border-blue-500 ring-2 ring-blue-500' : ''
|
||||
}`;
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: 'var(--bg-card)',
|
||||
border: `1px solid ${isRec ? 'var(--rec-red)' : isSelected ? 'var(--dragon-blue)' : 'var(--border)'}`,
|
||||
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 (
|
||||
<div className={cardClass} 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 style={cardStyle} onClick={() => onSelect(port.port_index)}>
|
||||
|
||||
{/* Recording bar */}
|
||||
<div style={{
|
||||
height: 3,
|
||||
background: isRec ? 'var(--rec-red)' : 'var(--bg-panel)',
|
||||
transition: 'background 0.2s',
|
||||
animation: isRec ? 'recBarPulse 1s ease-in-out infinite' : 'none',
|
||||
}} />
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
|
||||
{/* Header row */}
|
||||
<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 row */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3 text-sm text-gray-300">
|
||||
<div>
|
||||
<span className="text-gray-400">Frames:</span> {port.frame_count}
|
||||
</div>
|
||||
<div>
|
||||
<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)}
|
||||
{/* 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>
|
||||
|
||||
{/* Current file path */}
|
||||
<div className="mb-3 text-sm">
|
||||
<span className="text-gray-400">File:</span>
|
||||
<div className="text-gray-300 truncate font-mono text-xs mt-1">
|
||||
{/* 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)}
|
||||
</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>
|
||||
|
||||
{/* Codec info */}
|
||||
<div className="mb-4 text-sm text-gray-300">
|
||||
<span className="text-gray-400">Codec:</span> {port.codec}
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex gap-2">
|
||||
{!port.is_recording ? (
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{isRec ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartRecording(port.port_index);
|
||||
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',
|
||||
}}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm transition-colors"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
>■ Stop</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStopRecording(port.port_index);
|
||||
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',
|
||||
}}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
>▶ Start</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenConfig(port.port_index);
|
||||
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',
|
||||
}}
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm transition-colors"
|
||||
>
|
||||
Config
|
||||
</button>
|
||||
title="Configure"
|
||||
>⚙</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes recBarPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
@keyframes badgePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue