feat: redesign dashboard v3 layout
This commit is contained in:
File diff suppressed because it is too large
Load Diff
294
frontend/components/dashboard/ActivePipeline.tsx
Normal file
294
frontend/components/dashboard/ActivePipeline.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
'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;
|
||||
`;
|
||||
|
||||
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>Master flow / active pipeline</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>stage {String(index + 1).padStart(2, '0')}</NodeHint>
|
||||
</NodeCard>
|
||||
{index < nodes.length - 1 && <Connector $active={connectorActive} />}
|
||||
</NodeWrap>
|
||||
);
|
||||
})}
|
||||
</FlowMap>
|
||||
</PipelineCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user