feat(ui): add multi-zone layout and enhanced drag features to account flow viz

- Add 4-zone layout (top/left/right/bottom) based on account count
- Dynamic provider card sizing scales with account count
- Add localStorage persistence for card positions with reset button
- Implement click-to-toggle for account detail panel
- Add drag vs click detection to prevent accidental selections
- Fix connection curves to properly follow dragged cards using getBoundingClientRect
- Add fixed widths (w-44) to all zone cards for consistency
- Add top/bottom connector points to provider card
This commit is contained in:
kaitranntt committed 2025-12-17 15:09:16 -05:00
1 parent b0c6f74a80
commit 365f820c55
1 file changed
+542 -232
+542 -232
View File
@@ -233,11 +233,40 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
const { privacyMode } = usePrivacy(); const { privacyMode } = usePrivacy();
// Drag state for all cards (account IDs + 'provider') // Drag state for all cards (account IDs + 'provider')
const [dragOffsets, setDragOffsets] = useState<Record<string, DragOffset>>({});
const [draggingId, setDraggingId] = useState<string | null>(null); const [draggingId, setDraggingId] = useState<string | null>(null);
const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>( const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null null
); );
const didDragRef = useRef(false); // Track if actual movement occurred (for click vs drag detection)
// LocalStorage persistence for card positions
const storageKey = `ccs-flow-positions-${providerData.provider}`;
const loadSavedPositions = useCallback((): Record<string, DragOffset> => {
try {
const saved = localStorage.getItem(storageKey);
if (saved) return JSON.parse(saved);
} catch {
// Ignore parse errors
}
return {};
}, [storageKey]);
const [dragOffsets, setDragOffsets] = useState<Record<string, DragOffset>>(() =>
loadSavedPositions()
);
// Save positions to localStorage when they change
useEffect(() => {
if (Object.keys(dragOffsets).length > 0) {
localStorage.setItem(storageKey, JSON.stringify(dragOffsets));
}
}, [dragOffsets, storageKey]);
// Reset positions handler
const resetPositions = useCallback(() => {
setDragOffsets({});
localStorage.removeItem(storageKey);
}, [storageKey]);
// Pulse state: account IDs that are currently pulsing // Pulse state: account IDs that are currently pulsing
const [pulsingAccounts, setPulsingAccounts] = useState<Set<string>>(new Set()); const [pulsingAccounts, setPulsingAccounts] = useState<Set<string>>(new Set());
@@ -296,30 +325,60 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
if (!sourceEl) return; if (!sourceEl) return;
const sourceRect = sourceEl.getBoundingClientRect(); const sourceRect = sourceEl.getBoundingClientRect();
// Determine if this account is on the right side // Determine zone from data attribute
const isRightSide = sourceEl.hasAttribute('data-right-side'); const zone = sourceEl.getAttribute('data-zone') || 'left';
let startX: number, startY: number, destX: number, destY: number; let startX: number, startY: number, destX: number, destY: number;
if (isRightSide) { // Note: getBoundingClientRect already includes CSS transforms, so offset is implicit
// Right side account: connect from left edge to right edge of provider
startX = sourceRect.left - svgRect.left; switch (zone) {
startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; case 'right':
destX = destRect.right - svgRect.left; // Right side: connect from left edge of card to right edge of provider
destY = destRect.top + destRect.height / 2 - svgRect.top; startX = sourceRect.left - svgRect.left;
} else { startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
// Left side account: connect from right edge to left edge of provider destX = destRect.right - svgRect.left;
startX = sourceRect.right - svgRect.left; destY = destRect.top + destRect.height / 2 - svgRect.top;
startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; break;
destX = destRect.left - svgRect.left; case 'top':
destY = destRect.top + destRect.height / 2 - svgRect.top; // Top side: connect from bottom edge of card to top edge of provider
startX = sourceRect.left + sourceRect.width / 2 - svgRect.left;
startY = sourceRect.bottom - svgRect.top;
destX = destRect.left + destRect.width / 2 - svgRect.left;
destY = destRect.top - svgRect.top;
break;
case 'bottom':
// Bottom side: connect from top edge of card to bottom edge of provider
startX = sourceRect.left + sourceRect.width / 2 - svgRect.left;
startY = sourceRect.top - svgRect.top;
destX = destRect.left + destRect.width / 2 - svgRect.left;
destY = destRect.bottom - svgRect.top;
break;
default: // 'left'
// Left side: connect from right edge of card to left edge of provider
startX = sourceRect.right - svgRect.left;
startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
destX = destRect.left - svgRect.left;
destY = destRect.top + destRect.height / 2 - svgRect.top;
} }
// Bezier control points // Bezier control points - adjust based on zone direction
const cp1X = startX + (destX - startX) * 0.5; // Note: getBoundingClientRect already includes CSS transforms, so no manual offset needed
const cp1Y = startY; let cp1X: number, cp1Y: number, cp2X: number, cp2Y: number;
const cp2X = destX - (destX - startX) * 0.5;
const cp2Y = destY; if (zone === 'top' || zone === 'bottom') {
// Vertical connection - control points extend horizontally for curve
cp1X = startX;
cp1Y = startY + (destY - startY) * 0.5;
cp2X = destX;
cp2Y = destY - (destY - startY) * 0.5;
} else {
// Horizontal connection - control points extend vertically for curve
cp1X = startX + (destX - startX) * 0.5;
cp1Y = startY;
cp2X = destX - (destX - startX) * 0.5;
cp2Y = destY;
}
newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`); newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`);
}); });
@@ -337,21 +396,63 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
}; };
}, [calculatePaths]); }, [calculatePaths]);
// Recalculate paths when drag offsets change (including reset)
useEffect(() => {
const timer = setTimeout(calculatePaths, 10);
return () => clearTimeout(timer);
}, [dragOffsets, calculatePaths]);
const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280';
// Split accounts into left and right groups (when > 1 account, balance both sides) // Split accounts into zones based on count (top/left/right/bottom)
const { leftAccounts, rightAccounts } = useMemo(() => { const { leftAccounts, rightAccounts, topAccounts, bottomAccounts } = useMemo(() => {
if (accounts.length <= 1) { const count = accounts.length;
return { leftAccounts: accounts, rightAccounts: [] }; // 1-2 accounts: left only
if (count <= 2) {
return { leftAccounts: accounts, rightAccounts: [], topAccounts: [], bottomAccounts: [] };
} }
const mid = Math.ceil(accounts.length / 2); // 3-4 accounts: left and right
if (count <= 4) {
const mid = Math.ceil(count / 2);
return {
leftAccounts: accounts.slice(0, mid),
rightAccounts: accounts.slice(mid),
topAccounts: [],
bottomAccounts: [],
};
}
// 5-8 accounts: left, right, top
if (count <= 8) {
const perZone = Math.ceil(count / 3);
return {
leftAccounts: accounts.slice(0, perZone),
rightAccounts: accounts.slice(perZone, perZone * 2),
topAccounts: accounts.slice(perZone * 2),
bottomAccounts: [],
};
}
// 9+ accounts: all four zones
const perZone = Math.ceil(count / 4);
return { return {
leftAccounts: accounts.slice(0, mid), leftAccounts: accounts.slice(0, perZone),
rightAccounts: accounts.slice(mid), rightAccounts: accounts.slice(perZone, perZone * 2),
topAccounts: accounts.slice(perZone * 2, perZone * 3),
bottomAccounts: accounts.slice(perZone * 3),
}; };
}, [accounts]); }, [accounts]);
const hasRightAccounts = rightAccounts.length > 0; const hasRightAccounts = rightAccounts.length > 0;
const hasTopAccounts = topAccounts.length > 0;
const hasBottomAccounts = bottomAccounts.length > 0;
// Dynamic provider card size based on account count
const providerSize = useMemo(() => {
const count = accounts.length;
if (count >= 9) return 'w-64'; // 4 zones - largest
if (count >= 5) return 'w-60'; // 3 zones
if (count >= 3) return 'w-56'; // 2 zones
return 'w-52'; // 1 zone - default
}, [accounts.length]);
// Drag handlers // Drag handlers
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
@@ -361,6 +462,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
(e.target as HTMLElement).setPointerCapture(e.pointerId); (e.target as HTMLElement).setPointerCapture(e.pointerId);
const offset = dragOffsets[id] || { x: 0, y: 0 }; const offset = dragOffsets[id] || { x: 0, y: 0 };
dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y }; dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y };
didDragRef.current = false; // Reset movement flag
setDraggingId(id); setDraggingId(id);
}, },
[dragOffsets] [dragOffsets]
@@ -372,6 +474,10 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
const start = dragStartRef.current; const start = dragStartRef.current;
const dx = e.clientX - start.x; const dx = e.clientX - start.x;
const dy = e.clientY - start.y; const dy = e.clientY - start.y;
// Track if actual movement occurred (threshold of 3px)
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
didDragRef.current = true;
}
setDragOffsets((prev) => ({ setDragOffsets((prev) => ({
...prev, ...prev,
[draggingId]: { [draggingId]: {
@@ -406,10 +512,10 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
</button> </button>
)} )}
{/* Main visualization area - 3 column layout: Left Accounts | Provider | Right Accounts + Timeline */} {/* Main visualization area - Multi-zone layout */}
<div className="min-h-[320px] flex gap-4 px-4 py-6"> <div className="min-h-[320px] flex gap-4 px-4 py-6 self-stretch items-stretch">
{/* Flow visualization section */} {/* Flow visualization section */}
<div className="relative flex-1 flex items-center justify-between px-4"> <div className="relative flex-1 flex flex-col items-center justify-center px-4">
{/* SVG Canvas (Background) */} {/* SVG Canvas (Background) */}
<svg <svg
ref={svgRef} ref={svgRef}
@@ -473,185 +579,10 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
})} })}
</svg> </svg>
{/* Left Accounts */} {/* Top Zone Accounts */}
<div className="flex flex-col gap-3 z-10 w-48 justify-center flex-shrink-0"> {hasTopAccounts && (
{leftAccounts.map((account) => { <div className="flex flex-row gap-3 z-10 justify-center flex-wrap mb-8">
const originalIndex = accounts.findIndex((a) => a.id === account.id); {topAccounts.map((account) => {
const total = account.successCount + account.failureCount;
const isHovered = hoveredAccount === originalIndex;
const isDragging = draggingId === account.id;
const offset = getOffset(account.id);
return (
<div
key={account.id}
data-account-index={originalIndex}
onClick={() => !isDragging && setSelectedAccount(account)}
onMouseEnter={() => setHoveredAccount(originalIndex)}
onMouseLeave={() => setHoveredAccount(null)}
onPointerDown={(e) => handlePointerDown(account.id, e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group/card relative rounded-lg p-3 pr-6 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'border-l-2 select-none touch-none',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
)}
style={{
borderLeftColor: account.color,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
{/* Drag handle indicator */}
<GripVertical className="absolute top-1/2 left-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1 ml-3">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)}
</span>
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-opacity',
isHovered ? 'opacity-100' : 'opacity-0'
)}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground font-mono">
{total.toLocaleString()} reqs
</span>
<div className="flex gap-1">
{account.failureCount > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
)}
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
</div>
</div>
{/* Connector Dot - Right side */}
<div
className={cn(
'absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
isHovered && 'bg-foreground dark:bg-white border-transparent'
)}
/>
</div>
);
})}
</div>
{/* Center Provider */}
<div className="z-10 w-52 flex items-center flex-shrink-0">
{(() => {
const isDragging = draggingId === 'provider';
const offset = getOffset('provider');
return (
<div
data-provider-node
onPointerDown={(e) => handlePointerDown('provider', e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group relative w-full rounded-xl p-4 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border-2 border-border/50 dark:border-white/[0.08]',
// Idle animations: float + border glow (disabled when dragging)
!isDragging && 'animate-subtle-float animate-border-glow',
'select-none touch-none',
hoveredAccount !== null && 'scale-[1.02]',
isDragging && 'cursor-grabbing shadow-2xl scale-105 z-50'
)}
style={
{
'--glow-color': `${providerColor}60`,
borderColor: hoveredAccount !== null ? `${providerColor}80` : undefined,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
} as React.CSSProperties
}
>
{/* Drag handle */}
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
{/* Animated glow background */}
<div
className="absolute inset-0 rounded-xl animate-glow-pulse pointer-events-none"
style={{ '--glow-color': `${providerColor}30` } as React.CSSProperties}
/>
{/* Left Connector Point */}
<div
className="absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
style={{
backgroundColor: providerColor,
boxShadow: `0 0 0 4px var(--background)`,
}}
/>
{/* Right Connector Point - only show if there are right accounts */}
{hasRightAccounts && (
<div
className="absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
style={{
backgroundColor: providerColor,
boxShadow: `0 0 0 4px var(--background)`,
}}
/>
)}
<div className="flex items-center gap-3 mb-4 relative z-10">
{/* Provider icon with breathing animation */}
<div className="animate-icon-breathe">
<ProviderIcon provider={providerData.provider} size={36} withBackground />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground tracking-tight">
{providerData.displayName}
</h3>
<p className="text-[10px] text-muted-foreground font-medium uppercase">
Provider
</p>
</div>
</div>
<div className="space-y-2 relative z-10">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Total Requests</span>
<span className="text-foreground font-mono">
{totalRequests.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Accounts</span>
<span className="text-foreground font-mono">{accounts.length}</span>
</div>
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(100, (totalRequests / (maxRequests * accounts.length)) * 100)}%`,
backgroundColor: providerColor,
}}
/>
</div>
</div>
</div>
);
})()}
</div>
{/* Right Accounts */}
{hasRightAccounts && (
<div className="flex flex-col gap-3 z-10 w-44 justify-center">
{rightAccounts.map((account) => {
const originalIndex = accounts.findIndex((a) => a.id === account.id); const originalIndex = accounts.findIndex((a) => a.id === account.id);
const total = account.successCount + account.failureCount; const total = account.successCount + account.failureCount;
const isHovered = hoveredAccount === originalIndex; const isHovered = hoveredAccount === originalIndex;
@@ -662,8 +593,11 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
<div <div
key={account.id} key={account.id}
data-account-index={originalIndex} data-account-index={originalIndex}
data-right-side data-zone="top"
onClick={() => !isDragging && setSelectedAccount(account)} onClick={() =>
!didDragRef.current &&
setSelectedAccount((prev) => (prev?.id === account.id ? null : account))
}
onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseEnter={() => setHoveredAccount(originalIndex)}
onMouseLeave={() => setHoveredAccount(null)} onMouseLeave={() => setHoveredAccount(null)}
onPointerDown={(e) => handlePointerDown(account.id, e)} onPointerDown={(e) => handlePointerDown(account.id, e)}
@@ -671,27 +605,20 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp} onPointerCancel={handlePointerUp}
className={cn( className={cn(
'group/card relative rounded-lg p-3 pl-6 cursor-grab transition-shadow duration-200', 'group/card relative rounded-lg p-3 pb-5 w-44 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]', 'border border-border/50 dark:border-white/[0.08]',
'border-r-2 select-none touch-none', 'border-t-2 select-none touch-none',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60', isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50' isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
)} )}
style={{ style={{
borderRightColor: account.color, borderTopColor: account.color,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}} }}
> >
{/* Drag handle indicator */} <GripVertical className="absolute top-1 left-1/2 -translate-x-1/2 w-3 h-3 text-muted-foreground/40" />
<GripVertical className="absolute top-1/2 right-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" /> <div className="flex justify-between items-start mb-1 mt-2">
<div className="flex justify-between items-start mb-1 mr-3">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-opacity rotate-180',
isHovered ? 'opacity-100' : 'opacity-0'
)}
/>
<span <span
className={cn( className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]', 'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
@@ -700,22 +627,28 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
> >
{cleanEmail(account.email)} {cleanEmail(account.email)}
</span> </span>
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-opacity rotate-90',
isHovered ? 'opacity-100' : 'opacity-0'
)}
/>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
{account.failureCount > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
)}
</div>
<span className="text-[10px] text-muted-foreground font-mono"> <span className="text-[10px] text-muted-foreground font-mono">
{total.toLocaleString()} reqs {total.toLocaleString()} reqs
</span> </span>
<div className="flex gap-1">
{account.failureCount > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
)}
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
</div>
</div> </div>
{/* Connector Dot - Left side */} {/* Connector Dot - Bottom side */}
<div <div
className={cn( className={cn(
'absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border', 'absolute left-1/2 -bottom-1.5 w-3 h-3 rounded-full transform -translate-x-1/2 z-20 transition-colors border',
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600', 'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
isHovered && 'bg-foreground dark:bg-white border-transparent' isHovered && 'bg-foreground dark:bg-white border-transparent'
)} )}
@@ -725,14 +658,391 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
})} })}
</div> </div>
)} )}
{/* Middle Row: Left | Center Provider | Right */}
<div className="flex items-center justify-between flex-1">
{/* Left Accounts */}
<div className="flex flex-col gap-3 z-10 w-48 justify-center flex-shrink-0">
{leftAccounts.map((account) => {
const originalIndex = accounts.findIndex((a) => a.id === account.id);
const total = account.successCount + account.failureCount;
const isHovered = hoveredAccount === originalIndex;
const isDragging = draggingId === account.id;
const offset = getOffset(account.id);
return (
<div
key={account.id}
data-account-index={originalIndex}
data-zone="left"
onClick={() =>
!didDragRef.current &&
setSelectedAccount((prev) => (prev?.id === account.id ? null : account))
}
onMouseEnter={() => setHoveredAccount(originalIndex)}
onMouseLeave={() => setHoveredAccount(null)}
onPointerDown={(e) => handlePointerDown(account.id, e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group/card relative rounded-lg p-3 pr-6 w-44 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'border-l-2 select-none touch-none',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
)}
style={{
borderLeftColor: account.color,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
{/* Drag handle indicator */}
<GripVertical className="absolute top-1/2 left-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1 ml-3">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)}
</span>
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-opacity',
isHovered ? 'opacity-100' : 'opacity-0'
)}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground font-mono">
{total.toLocaleString()} reqs
</span>
<div className="flex gap-1">
{account.failureCount > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
)}
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
</div>
</div>
{/* Connector Dot - Right side */}
<div
className={cn(
'absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
isHovered && 'bg-foreground dark:bg-white border-transparent'
)}
/>
</div>
);
})}
</div>
{/* Center Provider */}
<div className={cn('z-10 flex items-center flex-shrink-0', providerSize)}>
{(() => {
const isDragging = draggingId === 'provider';
const offset = getOffset('provider');
return (
<div
data-provider-node
onPointerDown={(e) => handlePointerDown('provider', e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group relative w-full rounded-xl p-4 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border-2 border-border/50 dark:border-white/[0.08]',
// Idle animations: float + border glow (disabled when dragging)
!isDragging && 'animate-subtle-float animate-border-glow',
'select-none touch-none',
hoveredAccount !== null && 'scale-[1.02]',
isDragging && 'cursor-grabbing shadow-2xl scale-105 z-50'
)}
style={
{
'--glow-color': `${providerColor}60`,
borderColor: hoveredAccount !== null ? `${providerColor}80` : undefined,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
} as React.CSSProperties
}
>
{/* Drag handle */}
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
{/* Animated glow background */}
<div
className="absolute inset-0 rounded-xl animate-glow-pulse pointer-events-none"
style={{ '--glow-color': `${providerColor}30` } as React.CSSProperties}
/>
{/* Left Connector Point */}
<div
className="absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
style={{
backgroundColor: providerColor,
boxShadow: `0 0 0 4px var(--background)`,
}}
/>
{/* Right Connector Point - only show if there are right accounts */}
{hasRightAccounts && (
<div
className="absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
style={{
backgroundColor: providerColor,
boxShadow: `0 0 0 4px var(--background)`,
}}
/>
)}
{/* Top Connector Point - only show if there are top accounts */}
{hasTopAccounts && (
<div
className="absolute left-1/2 -top-1.5 w-3 h-3 rounded-full transform -translate-x-1/2"
style={{
backgroundColor: providerColor,
boxShadow: `0 0 0 4px var(--background)`,
}}
/>
)}
{/* Bottom Connector Point - only show if there are bottom accounts */}
{hasBottomAccounts && (
<div
className="absolute left-1/2 -bottom-1.5 w-3 h-3 rounded-full transform -translate-x-1/2"
style={{
backgroundColor: providerColor,
boxShadow: `0 0 0 4px var(--background)`,
}}
/>
)}
<div className="flex items-center gap-3 mb-4 relative z-10">
{/* Provider icon with breathing animation */}
<div className="animate-icon-breathe">
<ProviderIcon provider={providerData.provider} size={36} withBackground />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground tracking-tight">
{providerData.displayName}
</h3>
<p className="text-[10px] text-muted-foreground font-medium uppercase">
Provider
</p>
</div>
</div>
<div className="space-y-2 relative z-10">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Total Requests</span>
<span className="text-foreground font-mono">
{totalRequests.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Accounts</span>
<span className="text-foreground font-mono">{accounts.length}</span>
</div>
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(100, (totalRequests / (maxRequests * accounts.length)) * 100)}%`,
backgroundColor: providerColor,
}}
/>
</div>
</div>
</div>
);
})()}
</div>
{/* Right Accounts */}
{hasRightAccounts && (
<div className="flex flex-col gap-3 z-10 w-48 justify-center flex-shrink-0">
{rightAccounts.map((account) => {
const originalIndex = accounts.findIndex((a) => a.id === account.id);
const total = account.successCount + account.failureCount;
const isHovered = hoveredAccount === originalIndex;
const isDragging = draggingId === account.id;
const offset = getOffset(account.id);
return (
<div
key={account.id}
data-account-index={originalIndex}
data-zone="right"
onClick={() =>
!didDragRef.current &&
setSelectedAccount((prev) => (prev?.id === account.id ? null : account))
}
onMouseEnter={() => setHoveredAccount(originalIndex)}
onMouseLeave={() => setHoveredAccount(null)}
onPointerDown={(e) => handlePointerDown(account.id, e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group/card relative rounded-lg p-3 pl-6 w-44 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'border-r-2 select-none touch-none',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
)}
style={{
borderRightColor: account.color,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
{/* Drag handle indicator */}
<GripVertical className="absolute top-1/2 right-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1 mr-3">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-opacity rotate-180',
isHovered ? 'opacity-100' : 'opacity-0'
)}
/>
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
{account.failureCount > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
)}
</div>
<span className="text-[10px] text-muted-foreground font-mono">
{total.toLocaleString()} reqs
</span>
</div>
{/* Connector Dot - Left side */}
<div
className={cn(
'absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
isHovered && 'bg-foreground dark:bg-white border-transparent'
)}
/>
</div>
);
})}
</div>
)}
</div>
{/* Bottom Zone Accounts */}
{hasBottomAccounts && (
<div className="flex flex-row gap-3 z-10 justify-center flex-wrap mt-8">
{bottomAccounts.map((account) => {
const originalIndex = accounts.findIndex((a) => a.id === account.id);
const total = account.successCount + account.failureCount;
const isHovered = hoveredAccount === originalIndex;
const isDragging = draggingId === account.id;
const offset = getOffset(account.id);
return (
<div
key={account.id}
data-account-index={originalIndex}
data-zone="bottom"
onClick={() =>
!didDragRef.current &&
setSelectedAccount((prev) => (prev?.id === account.id ? null : account))
}
onMouseEnter={() => setHoveredAccount(originalIndex)}
onMouseLeave={() => setHoveredAccount(null)}
onPointerDown={(e) => handlePointerDown(account.id, e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group/card relative rounded-lg p-3 pt-6 w-44 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'border-b-2 select-none touch-none',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
)}
style={{
borderBottomColor: account.color,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
{/* Connector Dot - Top side */}
<div
className={cn(
'absolute left-1/2 -top-1.5 w-3 h-3 rounded-full transform -translate-x-1/2 z-20 transition-colors border',
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
isHovered && 'bg-foreground dark:bg-white border-transparent'
)}
/>
<GripVertical className="absolute bottom-1 left-1/2 -translate-x-1/2 w-3 h-3 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)}
</span>
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-opacity -rotate-90',
isHovered ? 'opacity-100' : 'opacity-0'
)}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground font-mono">
{total.toLocaleString()} reqs
</span>
<div className="flex gap-1">
{account.failureCount > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
)}
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
</div>
</div>
</div>
);
})}
</div>
)}
</div> </div>
{/* Right Section: Connection Timeline - Fixed compact width */} {/* Right Section: Connection Timeline - Fixed compact width */}
<div className="w-56 flex-shrink-0"> <div className="w-56 flex-shrink-0 self-stretch">
<ConnectionTimeline events={connectionEvents} privacyMode={privacyMode} /> <ConnectionTimeline events={connectionEvents} privacyMode={privacyMode} />
</div> </div>
</div> </div>
{/* Reset Layout Button */}
{Object.keys(dragOffsets).length > 0 && (
<div className="flex justify-center pb-2">
<button
onClick={resetPositions}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-muted/50"
>
Reset layout
</button>
</div>
)}
{/* Detail Panel - slides in from bottom, pushes content */} {/* Detail Panel - slides in from bottom, pushes content */}
<div <div
className={cn( className={cn(