296 lines
6.6 KiB
TypeScript
296 lines
6.6 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import styled, { keyframes } from 'styled-components';
|
|
|
|
export interface PipelineNode {
|
|
id: string;
|
|
label: string;
|
|
role: string;
|
|
state: 'idle' | 'active' | 'review' | 'blocked' | 'ready';
|
|
detail: string;
|
|
}
|
|
|
|
interface ActivePipelineProps {
|
|
activeTask: string;
|
|
focus: string;
|
|
reviewLoopCount: number;
|
|
escalationCount: number;
|
|
deployState: string;
|
|
nodes: PipelineNode[];
|
|
}
|
|
|
|
const flow = keyframes`
|
|
0% { transform: translateX(0); opacity: 0.15; }
|
|
40% { opacity: 0.8; }
|
|
100% { transform: translateX(20px); opacity: 0; }
|
|
`;
|
|
|
|
const PipelineCard = styled.section`
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-surface);
|
|
padding: var(--space-lg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
`;
|
|
|
|
const Header = styled.div`
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: var(--space-md);
|
|
align-items: flex-start;
|
|
flex-wrap: wrap;
|
|
`;
|
|
|
|
const Eyebrow = styled.div`
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
`;
|
|
|
|
const Title = styled.h2`
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
letter-spacing: -0.02em;
|
|
margin-top: 6px;
|
|
`;
|
|
|
|
const Summary = styled.p`
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
line-height: 1.6;
|
|
max-width: 780px;
|
|
text-transform: uppercase;
|
|
`;
|
|
|
|
const HeaderStats = styled.div`
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
|
gap: var(--space-sm);
|
|
|
|
@media (max-width: 767px) {
|
|
width: 100%;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
`;
|
|
|
|
const StatBox = styled.div`
|
|
border: 1px solid var(--border-color);
|
|
padding: 10px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
`;
|
|
|
|
const StatLabel = styled.span`
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
`;
|
|
|
|
const StatValue = styled.span`
|
|
font-size: 15px;
|
|
color: var(--text-primary);
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`;
|
|
|
|
const FlowMap = styled.div`
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 14px;
|
|
align-items: stretch;
|
|
|
|
@media (max-width: 1199px) {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
`;
|
|
|
|
const NodeWrap = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
|
|
@media (max-width: 1199px) {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
`;
|
|
|
|
const Connector = styled.div<{ $active?: boolean }>`
|
|
flex: 1;
|
|
min-width: 36px;
|
|
height: 1px;
|
|
background: ${({ $active }) => ($active ? 'rgba(245,245,245,0.45)' : 'var(--border-color)')};
|
|
position: relative;
|
|
overflow: hidden;
|
|
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: -2px;
|
|
left: -18px;
|
|
width: 18px;
|
|
height: 5px;
|
|
background: ${({ $active }) => ($active ? 'rgba(245,245,245,0.85)' : 'transparent')};
|
|
animation: ${({ $active }) => ($active ? flow : 'none')} 1.6s linear infinite;
|
|
}
|
|
|
|
@media (max-width: 1199px) {
|
|
width: 1px;
|
|
height: 24px;
|
|
min-width: 1px;
|
|
margin: 0 auto;
|
|
|
|
&::after {
|
|
top: -8px;
|
|
left: -2px;
|
|
width: 5px;
|
|
height: 10px;
|
|
animation: none;
|
|
}
|
|
}
|
|
`;
|
|
|
|
const stateBorder: Record<PipelineNode['state'], string> = {
|
|
idle: 'var(--border-color)',
|
|
active: '#6fc3ff',
|
|
review: '#ff7ac6',
|
|
blocked: '#ff8d7a',
|
|
ready: '#8dffb2',
|
|
};
|
|
|
|
const stateText: Record<PipelineNode['state'], string> = {
|
|
idle: 'IDLE',
|
|
active: 'ACTIVE',
|
|
review: 'REVIEW',
|
|
blocked: 'BLOCKED',
|
|
ready: 'READY',
|
|
};
|
|
|
|
const NodeCard = styled.div<{ $state: PipelineNode['state'] }>`
|
|
border: 1px solid ${({ $state }) => stateBorder[$state]};
|
|
padding: 14px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
min-height: 148px;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.015) 0%, rgba(255,255,255,0) 100%);
|
|
`;
|
|
|
|
const NodeTop = styled.div`
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: var(--space-sm);
|
|
align-items: flex-start;
|
|
`;
|
|
|
|
const NodeLabel = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
`;
|
|
|
|
const NodeName = styled.div`
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
`;
|
|
|
|
const NodeRole = styled.div`
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
`;
|
|
|
|
const StateBadge = styled.span<{ $state: PipelineNode['state'] }>`
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: ${({ $state }) => stateBorder[$state]};
|
|
border: 1px solid ${({ $state }) => stateBorder[$state]};
|
|
padding: 4px 6px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
white-space: nowrap;
|
|
`;
|
|
|
|
const NodeDetail = styled.div`
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: var(--text-primary);
|
|
`;
|
|
|
|
const NodeHint = styled.div`
|
|
margin-top: auto;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
`;
|
|
|
|
export default function ActivePipeline({
|
|
activeTask,
|
|
focus,
|
|
reviewLoopCount,
|
|
escalationCount,
|
|
deployState,
|
|
nodes,
|
|
}: ActivePipelineProps) {
|
|
return (
|
|
<PipelineCard>
|
|
<Header>
|
|
<div>
|
|
<Eyebrow>Active pipeline / snapshot</Eyebrow>
|
|
<Title>{activeTask}</Title>
|
|
<Summary>{focus}</Summary>
|
|
</div>
|
|
<HeaderStats>
|
|
<StatBox>
|
|
<StatLabel>review loop</StatLabel>
|
|
<StatValue>{reviewLoopCount}x</StatValue>
|
|
</StatBox>
|
|
<StatBox>
|
|
<StatLabel>escalations</StatLabel>
|
|
<StatValue>{escalationCount}</StatValue>
|
|
</StatBox>
|
|
<StatBox>
|
|
<StatLabel>deploy state</StatLabel>
|
|
<StatValue>{deployState}</StatValue>
|
|
</StatBox>
|
|
</HeaderStats>
|
|
</Header>
|
|
|
|
<FlowMap>
|
|
{nodes.map((node, index) => {
|
|
const connectorActive = node.state === 'active' || node.state === 'review' || node.state === 'ready';
|
|
return (
|
|
<NodeWrap key={node.id}>
|
|
<NodeCard $state={node.state}>
|
|
<NodeTop>
|
|
<NodeLabel>
|
|
<NodeName>{node.label}</NodeName>
|
|
<NodeRole>{node.role}</NodeRole>
|
|
</NodeLabel>
|
|
<StateBadge $state={node.state}>{stateText[node.state]}</StateBadge>
|
|
</NodeTop>
|
|
<NodeDetail>{node.detail}</NodeDetail>
|
|
<NodeHint>snapshot {String(index + 1).padStart(2, '0')}</NodeHint>
|
|
</NodeCard>
|
|
{index < nodes.length - 1 && <Connector $active={connectorActive} />}
|
|
</NodeWrap>
|
|
);
|
|
})}
|
|
</FlowMap>
|
|
</PipelineCard>
|
|
);
|
|
}
|