refactor(ui): split account-flow-viz into account/flow-viz/ directory

- extract 1,144-line monster file into 12 focused modules

- add hooks.ts, types.ts, utils.ts for reusable logic

- create backward-compatible re-export shim

- add barrel export in account/index.ts
This commit is contained in:
kaitranntt
2025-12-19 19:38:58 -05:00
parent 4a5b832a6e
commit 8fd35c8dd6
16 changed files with 1597 additions and 1140 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
/**
* Accounts Table Component
* Phase 03: REST API Routes & CRUD
*/
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Check } from 'lucide-react';
import { useSetDefaultAccount } from '@/hooks/use-accounts';
import type { Account } from '@/lib/api-client';
interface AccountsTableProps {
data: Account[];
defaultAccount: string | null;
}
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
const setDefaultMutation = useSetDefaultAccount();
const columns: ColumnDef<Account>[] = [
{
accessorKey: 'name',
header: 'Name',
size: 200,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
{row.original.name === defaultAccount && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded border border-primary/20">
default
</span>
)}
</div>
),
},
{
accessorKey: 'type',
header: 'Type',
size: 100,
cell: ({ row }) => (
<span className="capitalize text-muted-foreground">{row.original.type || 'oauth'}</span>
),
},
{
accessorKey: 'created',
header: 'Created',
size: 150,
cell: ({ row }) => {
const date = new Date(row.original.created);
return <span className="text-muted-foreground">{date.toLocaleDateString()}</span>;
},
},
{
accessorKey: 'last_used',
header: 'Last Used',
size: 150,
cell: ({ row }) => {
if (!row.original.last_used) return <span className="text-muted-foreground/50">-</span>;
const date = new Date(row.original.last_used);
return <span className="text-muted-foreground">{date.toLocaleDateString()}</span>;
},
},
{
id: 'actions',
header: 'Actions',
size: 100,
cell: ({ row }) => {
const isDefault = row.original.name === defaultAccount;
return (
<Button
variant={isDefault ? 'secondary' : 'default'}
size="sm"
className="h-8 px-2 w-full"
disabled={isDefault || setDefaultMutation.isPending}
onClick={() => setDefaultMutation.mutate(row.original.name)}
>
<Check className={`w-3 h-3 mr-1.5 ${isDefault ? 'opacity-50' : ''}`} />
{isDefault ? 'Active' : 'Set Default'}
</Button>
);
},
},
];
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
if (data.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No accounts found. Use <code className="text-sm bg-muted px-1 rounded">ccs login</code> to
add accounts.
</div>
);
}
return (
<div className="border rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const widthClass =
{
name: 'w-[200px]',
type: 'w-[100px]',
created: 'w-[150px]',
last_used: 'w-[150px]',
actions: 'w-[100px]',
}[header.id] || 'w-auto';
return (
<TableHead key={header.id} className={widthClass}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,128 @@
/**
* Add Account Dialog Component
* Triggers OAuth flow server-side to add another account to a provider
* Applies default preset when adding first account
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, ExternalLink, User } from 'lucide-react';
import { useStartAuth } from '@/hooks/use-cliproxy';
import { applyDefaultPreset } from '@/lib/preset-utils';
import { toast } from 'sonner';
interface AddAccountDialogProps {
open: boolean;
onClose: () => void;
provider: string;
displayName: string;
/** Whether this is the first account being added (triggers preset application) */
isFirstAccount?: boolean;
}
export function AddAccountDialog({
open,
onClose,
provider,
displayName,
isFirstAccount = false,
}: AddAccountDialogProps) {
const [nickname, setNickname] = useState('');
const startAuthMutation = useStartAuth();
const handleStartAuth = () => {
startAuthMutation.mutate(
{ provider, nickname: nickname.trim() || undefined },
{
onSuccess: async () => {
// Apply default preset if this is the first account
if (isFirstAccount) {
const result = await applyDefaultPreset(provider);
if (result.success && result.presetName) {
toast.success(`Applied "${result.presetName}" preset`);
} else if (!result.success) {
toast.warning('Account added, but failed to apply default preset');
}
}
setNickname('');
onClose();
},
}
);
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen && !startAuthMutation.isPending) {
setNickname('');
onClose();
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add {displayName} Account</DialogTitle>
<DialogDescription>
Click the button below to authenticate a new account. A browser window will open for
OAuth.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="nickname">Nickname (optional)</Label>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" />
<Input
id="nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="e.g., work, personal"
disabled={startAuthMutation.isPending}
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
A friendly name to identify this account. Auto-generated from email if left empty.
</p>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose} disabled={startAuthMutation.isPending}>
Cancel
</Button>
<Button onClick={handleStartAuth} disabled={startAuthMutation.isPending}>
{startAuthMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Authenticating...
</>
) : (
<>
<ExternalLink className="w-4 h-4 mr-2" />
Authenticate
</>
)}
</Button>
</div>
{startAuthMutation.isPending && (
<p className="text-sm text-center text-muted-foreground">
Complete the OAuth flow in your browser...
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,85 @@
/**
* Premium compact stats visualization for account cards
*/
import { cn } from '@/lib/utils';
import { CheckCircle2, XCircle } from 'lucide-react';
interface AccountCardStatsProps {
success: number;
failure: number;
showDetails: boolean;
}
export function AccountCardStats({ success, failure, showDetails }: AccountCardStatsProps) {
const total = success + failure;
const successRate = total > 0 ? (success / total) * 100 : 100;
return (
<div className="mt-2 space-y-2">
{/* Primary Row: Success Rate & Total */}
<div className="flex items-end justify-between px-0.5">
<div className="flex flex-col">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
Success Rate
</span>
<span
className={cn(
'text-sm font-mono font-bold leading-none mt-0.5',
successRate === 100
? 'text-emerald-600 dark:text-emerald-400'
: successRate >= 90
? 'text-amber-500'
: 'text-red-500'
)}
>
{Math.round(successRate)}%
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
Volume
</span>
<span className="text-xs font-mono font-medium text-foreground/80 leading-none mt-0.5">
{total.toLocaleString()}
</span>
</div>
</div>
{/* Detailed Stats - Collapsible */}
<div
className={cn(
'grid grid-cols-2 gap-2 overflow-hidden transition-all duration-300 ease-in-out',
showDetails ? 'max-h-20 opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'
)}
>
<div className="flex items-center gap-1.5 px-1.5 py-1 rounded-md bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/10">
<CheckCircle2 className="w-2.5 h-2.5 text-emerald-600 dark:text-emerald-400" />
<span className="text-[10px] font-mono font-bold text-emerald-600 dark:text-emerald-400">
{success}
</span>
</div>
<div
className={cn(
'flex items-center gap-1.5 px-1.5 py-1 rounded-md border',
failure > 0
? 'bg-red-500/5 dark:bg-red-500/10 border-red-500/20'
: 'bg-muted/10 border-transparent opacity-40'
)}
>
<XCircle
className={cn('w-2.5 h-2.5', failure > 0 ? 'text-red-500' : 'text-muted-foreground')}
/>
<span
className={cn(
'text-[10px] font-mono font-bold',
failure > 0 ? 'text-red-500' : 'text-muted-foreground'
)}
>
{failure}
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,127 @@
/**
* Account Card Component for Flow Visualization
*/
import { cn } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { GripVertical } from 'lucide-react';
import type { AccountData, DragOffset } from './types';
import { cleanEmail } from './utils';
import { AccountCardStats } from './account-card-stats';
type Zone = 'left' | 'right' | 'top' | 'bottom';
interface AccountCardProps {
account: AccountData;
zone: Zone;
originalIndex: number;
isHovered: boolean;
isDragging: boolean;
offset: DragOffset;
showDetails: boolean;
privacyMode: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: () => void;
}
const BORDER_SIDE_MAP: Record<Zone, string> = {
left: 'border-l-2',
right: 'border-r-2',
top: 'border-t-2',
bottom: 'border-b-2',
};
const CONNECTOR_POSITION_MAP: Record<Zone, string> = {
left: 'top-1/2 -right-1.5 -translate-y-1/2',
right: 'top-1/2 -left-1.5 -translate-y-1/2',
top: 'left-1/2 -bottom-1.5 -translate-x-1/2',
bottom: 'left-1/2 -top-1.5 -translate-x-1/2',
};
function getBorderColorStyle(zone: Zone, color: string): React.CSSProperties {
switch (zone) {
case 'left':
return { borderLeftColor: color };
case 'right':
return { borderRightColor: color };
case 'top':
return { borderTopColor: color };
case 'bottom':
return { borderBottomColor: color };
}
}
export function AccountCard({
account,
zone,
originalIndex,
isHovered,
isDragging,
offset,
showDetails,
privacyMode,
onMouseEnter,
onMouseLeave,
onPointerDown,
onPointerMove,
onPointerUp,
}: AccountCardProps) {
const borderSide = BORDER_SIDE_MAP[zone];
const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
return (
<div
data-account-index={originalIndex}
data-zone={zone}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={cn(
'group/card relative rounded-lg p-3 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]',
borderSide,
'select-none touch-none',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
)}
style={{
...borderColor,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1 mr-4">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)}
</span>
</div>
<AccountCardStats
success={account.successCount}
failure={account.failureCount}
showDetails={showDetails}
/>
<div
className={cn(
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
connectorPosition,
isHovered && 'bg-foreground dark:bg-white border-transparent'
)}
/>
</div>
);
}
@@ -0,0 +1,107 @@
/**
* Connection Timeline Component - right sidebar panel
*/
import { cn } from '@/lib/utils';
import { STATUS_COLORS } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { Activity } from 'lucide-react';
import type { ConnectionEvent } from './types';
import { cleanEmail, formatTimelineTime } from './utils';
interface ConnectionTimelineProps {
events: ConnectionEvent[];
privacyMode: boolean;
}
export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) {
if (events.length === 0) {
return (
<div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]">
<div className="text-xs text-muted-foreground font-mono">No recent connections</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 mb-3 px-2">
<Activity className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
Connection Timeline
</span>
</div>
{/* Timeline container */}
<div
className={cn(
'flex-1 rounded-xl p-4 overflow-y-auto',
'bg-muted/20 dark:bg-zinc-900/40 backdrop-blur-sm',
'border border-border/30 dark:border-white/[0.05]'
)}
>
<div className="relative">
{/* Vertical line */}
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border/50 dark:bg-white/[0.08]" />
{/* Events */}
<div className="space-y-3">
{events.map((event) => {
const statusColor =
event.status === 'success'
? STATUS_COLORS.success
: event.status === 'failed'
? STATUS_COLORS.failed
: STATUS_COLORS.degraded;
return (
<div key={event.id} className="relative flex items-start gap-3 pl-1">
{/* Timeline dot */}
<div
className={cn(
'relative z-10 w-3.5 h-3.5 rounded-full flex-shrink-0 mt-0.5',
'ring-2 ring-background dark:ring-zinc-950'
)}
style={{ backgroundColor: statusColor }}
/>
{/* Event content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
'text-[10px] font-mono text-foreground truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(event.accountEmail)}
</span>
<span className="text-[9px] text-muted-foreground font-mono flex-shrink-0">
{formatTimelineTime(event.timestamp)}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span
className="text-[9px] font-medium uppercase"
style={{ color: statusColor }}
>
{event.status}
</span>
{event.latencyMs && (
<span className="text-[9px] text-muted-foreground font-mono">
{event.latencyMs}ms
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
/**
* SVG Connection Paths Component
*/
import type { AccountData } from './types';
import { getConnectionColor } from './utils';
interface FlowPathsProps {
paths: string[];
accounts: AccountData[];
maxRequests: number;
hoveredAccount: number | null;
pulsingAccounts: Set<string>;
}
export function FlowPaths({
paths,
accounts,
maxRequests,
hoveredAccount,
pulsingAccounts,
}: FlowPathsProps) {
return (
<>
<defs>
<filter
id="flow-glow"
x="-20%"
y="-20%"
width="140%"
height="140%"
filterUnits="userSpaceOnUse"
>
<feGaussianBlur stdDeviation="3" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
{paths.map((d, i) => {
const account = accounts[i];
if (!account) return null;
const total = account.successCount + account.failureCount;
const strokeWidth = Math.max(2, (total / maxRequests) * 10);
const isHovered = hoveredAccount === i;
const isDimmed = hoveredAccount !== null && hoveredAccount !== i;
const isPulsing = pulsingAccounts.has(account.id);
const connectionColor = getConnectionColor(i);
return (
<g key={i}>
<path
d={d}
fill="none"
stroke={connectionColor}
strokeWidth={strokeWidth}
strokeOpacity={isHovered ? 0.8 : isDimmed ? 0.15 : 0.4}
strokeLinecap="round"
filter={isHovered ? 'url(#flow-glow)' : undefined}
className="transition-all duration-300"
/>
{isPulsing && (
<>
<path
d={d}
fill="none"
stroke={account.color}
strokeWidth={strokeWidth * 2}
strokeLinecap="round"
filter="url(#flow-glow)"
className="animate-request-pulse"
/>
<circle
r={6}
fill={account.color}
filter="url(#flow-glow)"
style={{
offsetPath: `path('${d}')`,
offsetDistance: '0%',
animation: 'travel-dot 1.5s cubic-bezier(0.4, 0, 0.2, 1) forwards',
}}
/>
</>
)}
</g>
);
})}
</>
);
}
@@ -0,0 +1,61 @@
/**
* Flow Visualization Header Component
*/
import { cn } from '@/lib/utils';
import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react';
interface FlowVizHeaderProps {
onBack?: () => void;
showDetails: boolean;
onToggleDetails: () => void;
hasCustomPositions: boolean;
onResetPositions: () => void;
}
export function FlowVizHeader({
onBack,
showDetails,
onToggleDetails,
hasCustomPositions,
onResetPositions,
}: FlowVizHeaderProps) {
return (
<div className="flex items-center justify-between px-3 py-1.5">
{onBack ? (
<button
onClick={onBack}
className="group flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border/50"
>
<ChevronRight className="w-3.5 h-3.5 rotate-180 transition-transform group-hover:-translate-x-0.5" />
<span>Back to providers</span>
</button>
) : (
<div />
)}
<div className="flex items-center gap-2">
<button
onClick={onToggleDetails}
className={cn(
'flex items-center gap-1.5 text-xs font-medium transition-all duration-200 px-3 py-1.5 rounded-md border shadow-sm',
showDetails
? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90'
: 'bg-background text-muted-foreground hover:text-foreground border-border/60 hover:border-border hover:bg-muted/50'
)}
>
{showDetails ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
<span>{showDetails ? 'Hide Details' : 'Show Details'}</span>
</button>
{hasCustomPositions && (
<button
onClick={onResetPositions}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md border border-border/60 hover:border-border bg-background hover:bg-muted/50 shadow-sm"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Reset layout</span>
</button>
)}
</div>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Custom hooks for drag and position management
*/
import { useRef, useState, useEffect, useCallback } from 'react';
import type { DragOffset, ContainerExpansion } from './types';
interface UseDragPositionsOptions {
storageKey: string;
onDrag?: () => void;
}
interface UseDragPositionsReturn {
dragOffsets: Record<string, DragOffset>;
draggingId: string | null;
didDragRef: React.MutableRefObject<boolean>;
handlePointerDown: (id: string, e: React.PointerEvent) => void;
handlePointerMove: (e: React.PointerEvent) => void;
handlePointerUp: () => void;
getOffset: (id: string) => DragOffset;
resetPositions: () => void;
hasCustomPositions: boolean;
}
/**
* Hook for managing draggable card positions with localStorage persistence
*/
export function useDragPositions({
storageKey,
onDrag,
}: UseDragPositionsOptions): UseDragPositionsReturn {
// Drag state
const [draggingId, setDraggingId] = useState<string | null>(null);
const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null
);
const didDragRef = useRef(false);
// Load saved positions from localStorage
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]);
// Drag handlers
const handlePointerDown = useCallback(
(id: string, e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const offset = dragOffsets[id] || { x: 0, y: 0 };
dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y };
didDragRef.current = false;
setDraggingId(id);
},
[dragOffsets]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingId || !dragStartRef.current) return;
const start = dragStartRef.current;
const dx = e.clientX - start.x;
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) => ({
...prev,
[draggingId]: {
x: start.offsetX + dx,
y: start.offsetY + dy,
},
}));
// Notify parent to recalculate paths
if (onDrag) {
requestAnimationFrame(onDrag);
}
},
[draggingId, onDrag]
);
const handlePointerUp = useCallback(() => {
setDraggingId(null);
dragStartRef.current = null;
}, []);
// Get offset for a card
const getOffset = (id: string): DragOffset => dragOffsets[id] || { x: 0, y: 0 };
return {
dragOffsets,
draggingId,
didDragRef,
handlePointerDown,
handlePointerMove,
handlePointerUp,
getOffset,
resetPositions,
hasCustomPositions: Object.keys(dragOffsets).length > 0,
};
}
/**
* Calculate container expansion based on drag offsets
*/
export function useContainerExpansion(dragOffsets: Record<string, DragOffset>): ContainerExpansion {
let minY = 0,
maxY = 0;
Object.values(dragOffsets).forEach((offset) => {
minY = Math.min(minY, offset.y);
maxY = Math.max(maxY, offset.y);
});
return {
paddingTop: Math.max(0, -minY),
paddingBottom: Math.max(0, maxY),
extraHeight: Math.max(0, Math.abs(minY), Math.abs(maxY)) * 2,
};
}
interface AccountLike {
id: string;
successCount: number;
failureCount: number;
}
/**
* Hook for detecting new activity and triggering pulse animations
*/
export function usePulseAnimation(accounts: AccountLike[]): Set<string> {
const [pulsingAccounts, setPulsingAccounts] = useState<Set<string>>(new Set());
const prevCountsRef = useRef<Record<string, number>>({});
// Detect new activity and trigger pulse animation
useEffect(() => {
const newPulsing = new Set<string>();
const newCounts: Record<string, number> = {};
accounts.forEach((account) => {
const currentCount = account.successCount + account.failureCount;
newCounts[account.id] = currentCount;
const prev = prevCountsRef.current[account.id];
if (prev !== undefined && currentCount > prev) {
newPulsing.add(account.id);
}
});
prevCountsRef.current = newCounts;
if (newPulsing.size > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Valid pattern for animation triggers
setPulsingAccounts(newPulsing);
const timer = setTimeout(() => setPulsingAccounts(new Set()), 2000);
return () => clearTimeout(timer);
}
}, [accounts]);
return pulsingAccounts;
}
@@ -0,0 +1,202 @@
/**
* Account Flow Visualization
* Custom SVG bezier curve visualization showing request flow from accounts to providers
*/
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { PROVIDER_COLORS } from '@/lib/provider-config';
import { usePrivacy } from '@/contexts/privacy-context';
import type { AccountFlowVizProps } from './types';
import { MAX_TIMELINE_EVENTS, generateConnectionEvents } from './utils';
import { calculateBezierPaths } from './path-utils';
import { splitAccountsIntoZones, getProviderSizeClass } from './zone-utils';
import { useDragPositions, useContainerExpansion, usePulseAnimation } from './hooks';
import { ConnectionTimeline } from './connection-timeline';
import { AccountCard } from './account-card';
import { ProviderCard } from './provider-card';
import { FlowPaths } from './flow-paths';
import { FlowVizHeader } from './flow-viz-header';
// Re-export types for backward compatibility
export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './types';
export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [hoveredAccount, setHoveredAccount] = useState<number | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [paths, setPaths] = useState<string[]>([]);
const { privacyMode } = usePrivacy();
const { accounts } = providerData;
const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1);
const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0);
const calculatePaths = useCallback(() => {
const newPaths = calculateBezierPaths({ containerRef, svgRef, accounts });
if (newPaths.length > 0) setPaths(newPaths);
}, [accounts]);
const storageKey = `ccs-flow-positions-${providerData.provider}`;
const {
dragOffsets,
draggingId,
handlePointerDown,
handlePointerMove,
handlePointerUp,
getOffset,
resetPositions,
hasCustomPositions,
} = useDragPositions({ storageKey, onDrag: calculatePaths });
const containerExpansion = useContainerExpansion(dragOffsets);
const pulsingAccounts = usePulseAnimation(accounts);
const connectionEvents = useMemo(
() => generateConnectionEvents(accounts).slice(0, MAX_TIMELINE_EVENTS),
[accounts]
);
useEffect(() => {
const timer = setTimeout(calculatePaths, 50);
window.addEventListener('resize', calculatePaths);
return () => {
clearTimeout(timer);
window.removeEventListener('resize', calculatePaths);
};
}, [calculatePaths]);
useEffect(() => {
const timer = setTimeout(calculatePaths, 10);
return () => clearTimeout(timer);
}, [dragOffsets, calculatePaths]);
useEffect(() => {
const startTime = Date.now();
const duration = 350;
const animate = () => {
calculatePaths();
if (Date.now() - startTime < duration) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [showDetails, calculatePaths]);
const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280';
const zones = useMemo(() => splitAccountsIntoZones(accounts), [accounts]);
const { leftAccounts, rightAccounts, topAccounts, bottomAccounts } = zones;
const hasRightAccounts = rightAccounts.length > 0;
const hasTopAccounts = topAccounts.length > 0;
const hasBottomAccounts = bottomAccounts.length > 0;
const providerSize = useMemo(() => getProviderSizeClass(accounts.length), [accounts.length]);
const renderAccountCards = (
accountList: typeof accounts,
zone: 'left' | 'right' | 'top' | 'bottom'
) =>
accountList.map((account) => {
const originalIndex = accounts.findIndex((a) => a.id === account.id);
return (
<AccountCard
key={account.id}
account={account}
zone={zone}
originalIndex={originalIndex}
isHovered={hoveredAccount === originalIndex}
isDragging={draggingId === account.id}
offset={getOffset(account.id)}
showDetails={showDetails}
privacyMode={privacyMode}
onMouseEnter={() => setHoveredAccount(originalIndex)}
onMouseLeave={() => setHoveredAccount(null)}
onPointerDown={(e) => handlePointerDown(account.id, e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
/>
);
});
return (
<div className="flex flex-col" ref={containerRef}>
<FlowVizHeader
onBack={onBack}
showDetails={showDetails}
onToggleDetails={() => setShowDetails(!showDetails)}
hasCustomPositions={hasCustomPositions}
onResetPositions={resetPositions}
/>
<div className="min-h-[320px] flex gap-4 px-4 py-6 self-stretch items-stretch transition-all duration-200">
<div
className="relative flex-1 flex flex-col items-stretch justify-center px-4"
style={{
paddingTop: `${24 + containerExpansion.paddingTop}px`,
paddingBottom: `${24 + containerExpansion.paddingBottom}px`,
minHeight: `${320 + containerExpansion.extraHeight}px`,
}}
>
<svg
ref={svgRef}
className="absolute inset-0 w-full h-full pointer-events-none z-0 overflow-visible"
>
<FlowPaths
paths={paths}
accounts={accounts}
maxRequests={maxRequests}
hoveredAccount={hoveredAccount}
pulsingAccounts={pulsingAccounts}
/>
</svg>
{hasTopAccounts && (
<div className="flex flex-row gap-3 z-10 justify-center flex-wrap mb-8">
{renderAccountCards(topAccounts, 'top')}
</div>
)}
<div className="flex items-center justify-between gap-8 flex-1">
<div className="flex flex-col gap-3 z-10 w-48 justify-center flex-shrink-0">
{renderAccountCards(leftAccounts, 'left')}
</div>
<div className={cn('z-10 flex items-center flex-shrink-0', providerSize)}>
<ProviderCard
providerData={providerData}
providerColor={providerColor}
totalRequests={totalRequests}
maxRequests={maxRequests}
isDragging={draggingId === 'provider'}
offset={getOffset('provider')}
hoveredAccount={hoveredAccount}
hasRightAccounts={hasRightAccounts}
hasTopAccounts={hasTopAccounts}
hasBottomAccounts={hasBottomAccounts}
onPointerDown={(e) => handlePointerDown('provider', e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
/>
</div>
{hasRightAccounts && (
<div className="flex flex-col gap-3 z-10 w-48 justify-center flex-shrink-0">
{renderAccountCards(rightAccounts, 'right')}
</div>
)}
</div>
{hasBottomAccounts && (
<div className="flex flex-row gap-3 z-10 justify-center flex-wrap mt-8">
{renderAccountCards(bottomAccounts, 'bottom')}
</div>
)}
</div>
<div className="w-56 flex-shrink-0 self-stretch relative">
<div className="absolute inset-0">
<ConnectionTimeline events={connectionEvents} privacyMode={privacyMode} />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,96 @@
/**
* SVG path calculation utilities for bezier curves
*/
import type { AccountData, AccountZone } from './types';
interface PathCalculationParams {
containerRef: React.RefObject<HTMLDivElement | null>;
svgRef: React.RefObject<SVGSVGElement | null>;
accounts: AccountData[];
}
/**
* Calculate SVG bezier curve paths from account cards to provider node
*/
export function calculateBezierPaths({
containerRef,
svgRef,
accounts,
}: PathCalculationParams): string[] {
if (!containerRef.current || !svgRef.current) return [];
const container = containerRef.current;
const svg = svgRef.current;
const svgRect = svg.getBoundingClientRect();
const destEl = container.querySelector('[data-provider-node]');
if (!destEl) return [];
const destRect = destEl.getBoundingClientRect();
const newPaths: string[] = [];
accounts.forEach((_, i) => {
const sourceEl = container.querySelector(`[data-account-index="${i}"]`);
if (!sourceEl) return;
const sourceRect = sourceEl.getBoundingClientRect();
// Determine zone from data attribute
const zone = (sourceEl.getAttribute('data-zone') || 'left') as AccountZone;
let startX: number, startY: number, destX: number, destY: number;
// Note: getBoundingClientRect already includes CSS transforms, so offset is implicit
switch (zone) {
case 'right':
// Right side: connect from left edge of card to right edge of provider
startX = sourceRect.left - svgRect.left;
startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
destX = destRect.right - svgRect.left;
destY = destRect.top + destRect.height / 2 - svgRect.top;
break;
case '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 - adjust based on zone direction
let cp1X: number, cp1Y: number, cp2X: number, cp2Y: number;
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}`);
});
return newPaths;
}
@@ -0,0 +1,143 @@
/**
* Provider Card Component for Flow Visualization
*/
import { cn } from '@/lib/utils';
import { ProviderIcon } from '@/components/shared/provider-icon';
import { GripVertical } from 'lucide-react';
import type { DragOffset, ProviderData } from './types';
interface ProviderCardProps {
providerData: ProviderData;
providerColor: string;
totalRequests: number;
maxRequests: number;
isDragging: boolean;
offset: DragOffset;
hoveredAccount: number | null;
hasRightAccounts: boolean;
hasTopAccounts: boolean;
hasBottomAccounts: boolean;
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: () => void;
}
export function ProviderCard({
providerData,
providerColor,
totalRequests,
maxRequests,
isDragging,
offset,
hoveredAccount,
hasRightAccounts,
hasTopAccounts,
hasBottomAccounts,
onPointerDown,
onPointerMove,
onPointerUp,
}: ProviderCardProps) {
const { accounts } = providerData;
return (
<div
data-provider-node
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
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]',
!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
}
>
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
<div
className="absolute inset-0 rounded-xl animate-glow-pulse pointer-events-none"
style={{ '--glow-color': `${providerColor}30` } as React.CSSProperties}
/>
{/* Connector Points */}
<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)`,
}}
/>
{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)`,
}}
/>
)}
{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)`,
}}
/>
)}
{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">
<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>
);
}
@@ -0,0 +1,57 @@
/**
* Type definitions for Account Flow Visualization
*/
/** Position offset for draggable cards */
export interface DragOffset {
x: number;
y: number;
}
export interface AccountData {
id: string;
email: string;
provider: string;
successCount: number;
failureCount: number;
lastUsedAt?: string;
color: string;
}
export interface ProviderData {
provider: string;
displayName: string;
totalRequests: number;
accounts: AccountData[];
}
export interface AccountFlowVizProps {
providerData: ProviderData;
onBack?: () => void;
}
export interface ConnectionEvent {
id: string;
timestamp: Date;
accountEmail: string;
status: 'success' | 'failed' | 'pending';
latencyMs?: number;
}
/** Zone type for account card placement */
export type AccountZone = 'left' | 'right' | 'top' | 'bottom';
/** Container expansion state */
export interface ContainerExpansion {
paddingTop: number;
paddingBottom: number;
extraHeight: number;
}
/** Account zone distribution */
export interface AccountZones {
leftAccounts: AccountData[];
rightAccounts: AccountData[];
topAccounts: AccountData[];
bottomAccounts: AccountData[];
}
@@ -0,0 +1,87 @@
/**
* Utility functions for Account Flow Visualization
*/
import type { AccountData, ConnectionEvent } from './types';
// Maximum events to display in the Connection Timeline to prevent performance issues
export const MAX_TIMELINE_EVENTS = 100;
// Earthy, sophisticated color palette for connection lines - works in both light/dark themes
export const CONNECTION_COLORS = [
'#3b3c36', // Charcoal Brown - urban mystery
'#568203', // Forest Moss - woodland depth
'#8d4557', // Vintage Berry - timeless elegance
'#da9100', // Harvest Gold - sun-drenched warmth
'#3c6c82', // Blue Slate - cool authority
'#c96907', // Burnt Caramel - earthy comfort
];
/** Get a muted connection color based on index */
export function getConnectionColor(index: number): string {
return CONNECTION_COLORS[index % CONNECTION_COLORS.length];
}
/** Strip common email domains for cleaner display */
export function cleanEmail(email: string): string {
return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
}
/** Format timestamp for timeline display */
export function formatTimelineTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'now';
if (diffMins < 60) return `${diffMins}m`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h`;
return `${Math.floor(diffHours / 24)}d`;
}
/** Generate connection events from real account data */
export function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] {
const events: ConnectionEvent[] = [];
accounts.forEach((account) => {
const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date();
// Helper to add events
const addEvents = (count: number, status: 'success' | 'failed') => {
for (let i = 0; i < count; i++) {
// Simulate timestamps:
// - Distribute events over a 24-hour window relative to lastUsed
// - Add random jitter so events from different accounts mix
const timeOffset = Math.floor(Math.random() * 24 * 60 * 60 * 1000 * (i / (count || 1)));
const timestamp = new Date(lastUsed.getTime() - timeOffset);
// Add small random jitter (+/-5 mins) to avoid exact overlaps
const jitter = Math.floor((Math.random() - 0.5) * 10 * 60 * 1000);
timestamp.setTime(timestamp.getTime() + jitter);
// Sanity check: don't go into the future relative to "now"
const now = new Date();
if (timestamp > now) timestamp.setTime(now.getTime());
events.push({
id: `${account.id}-${status}-${i}`,
timestamp,
accountEmail: account.email,
status,
// Simulate realistic latency (success: 50-200ms, failed: 200-5000ms)
latencyMs:
status === 'success'
? 50 + Math.floor(Math.random() * 150)
: 200 + Math.floor(Math.random() * 4800),
});
}
};
addEvents(account.successCount, 'success');
addEvents(account.failureCount, 'failed');
});
// Sort by timestamp descending (most recent first)
return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
}
@@ -0,0 +1,63 @@
/**
* Zone distribution utilities for account cards
*/
import type { AccountData, AccountZones } from './types';
/**
* Split accounts into zones based on count (top/left/right/bottom)
*/
export function splitAccountsIntoZones(accounts: AccountData[]): AccountZones {
const count = accounts.length;
// 1-2 accounts: left only
if (count <= 2) {
return {
leftAccounts: accounts,
rightAccounts: [],
topAccounts: [],
bottomAccounts: [],
};
}
// 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 {
leftAccounts: accounts.slice(0, perZone),
rightAccounts: accounts.slice(perZone, perZone * 2),
topAccounts: accounts.slice(perZone * 2, perZone * 3),
bottomAccounts: accounts.slice(perZone * 3),
};
}
/**
* Get provider card size class based on account count
*/
export function getProviderSizeClass(accountCount: number): string {
if (accountCount >= 9) return 'w-64'; // 4 zones - largest
if (accountCount >= 5) return 'w-60'; // 3 zones
if (accountCount >= 3) return 'w-56'; // 2 zones
return 'w-52'; // 1 zone - default
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Account Components Barrel Export
*/
// Main components
export { AccountsTable } from './accounts-table';
export { AddAccountDialog } from './add-account-dialog';
// Flow visualization (from subdirectory)
export { AccountFlowViz } from './flow-viz';
export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './flow-viz';