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 {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue