mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 02:14:32 +00:00
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:
1 parent
b0c6f74a80
commit
365f820c55
1 file changed
+542
-232
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user