@dilipod/ui 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dilipod/ui",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Dilipod Design System - Shared UI components and styles",
5
5
  "author": "Dilipod <hello@dilipod.com>",
6
6
  "license": "MIT",
@@ -77,6 +77,7 @@
77
77
  "@radix-ui/react-tabs": "^1.1.13",
78
78
  "@radix-ui/react-toast": "^1.2.15",
79
79
  "@radix-ui/react-tooltip": "^1.2.8",
80
+ "@xyflow/react": "^12.10.0",
80
81
  "class-variance-authority": "^0.7.1",
81
82
  "clsx": "^2.1.1",
82
83
  "tailwind-merge": "^3.3.0",
@@ -0,0 +1,208 @@
1
+ 'use client'
2
+
3
+ import { useState, ReactNode } from 'react'
4
+ import { Button } from './button'
5
+ import { Textarea } from './textarea'
6
+ import {
7
+ CircleNotch,
8
+ PaperPlaneTilt,
9
+ CaretDown,
10
+ CaretUp
11
+ } from '@phosphor-icons/react'
12
+
13
+ // ============================================
14
+ // Types
15
+ // ============================================
16
+
17
+ export interface Activity {
18
+ /** Unique identifier for the activity */
19
+ id: string
20
+ /** Type of activity (e.g., 'note', 'status_change', 'workflow_update', 'assignment') */
21
+ activity_type: string
22
+ /** Main content of the activity */
23
+ content: string
24
+ /** User who created the activity (display name or email) */
25
+ user: string
26
+ /** When the activity was created */
27
+ created_at: string | Date
28
+ /** Additional metadata */
29
+ metadata?: Record<string, unknown>
30
+ }
31
+
32
+ export interface ActivityTimelineProps {
33
+ /** List of activities to display */
34
+ activities: Activity[]
35
+ /** Whether the activities are loading */
36
+ loading?: boolean
37
+ /** Labels for different activity types (type -> label) */
38
+ activityLabels?: Record<string, string>
39
+ /** Number of activities to show before collapsing (default: 3) */
40
+ collapsedCount?: number
41
+ /** Whether to show the note input form */
42
+ showNoteInput?: boolean
43
+ /** Placeholder for the note input */
44
+ notePlaceholder?: string
45
+ /** Callback when a new note is submitted */
46
+ onAddNote?: (note: string) => Promise<void>
47
+ /** Whether the component is currently submitting a note */
48
+ submitting?: boolean
49
+ /** Custom date formatter function */
50
+ formatDate?: (date: Date | string) => string
51
+ /** Custom loading component */
52
+ loadingComponent?: ReactNode
53
+ /** Custom empty state message */
54
+ emptyMessage?: string
55
+ /** Additional CSS class */
56
+ className?: string
57
+ }
58
+
59
+ // ============================================
60
+ // Default Date Formatter
61
+ // ============================================
62
+
63
+ function defaultFormatDate(date: Date | string): string {
64
+ const d = typeof date === 'string' ? new Date(date) : date
65
+ const now = new Date()
66
+ const diffMs = now.getTime() - d.getTime()
67
+ const diffMins = Math.floor(diffMs / 60000)
68
+ const diffHours = Math.floor(diffMs / 3600000)
69
+ const diffDays = Math.floor(diffMs / 86400000)
70
+
71
+ if (diffMins < 1) return 'just now'
72
+ if (diffMins < 60) return `${diffMins}m ago`
73
+ if (diffHours < 24) return `${diffHours}h ago`
74
+ if (diffDays < 7) return `${diffDays}d ago`
75
+
76
+ return d.toLocaleDateString()
77
+ }
78
+
79
+ // ============================================
80
+ // Main Component
81
+ // ============================================
82
+
83
+ export function ActivityTimeline({
84
+ activities,
85
+ loading = false,
86
+ activityLabels = {},
87
+ collapsedCount = 3,
88
+ showNoteInput = true,
89
+ notePlaceholder = 'Add a note...',
90
+ onAddNote,
91
+ submitting = false,
92
+ formatDate = defaultFormatDate,
93
+ loadingComponent,
94
+ emptyMessage = 'No activity yet',
95
+ className = '',
96
+ }: ActivityTimelineProps) {
97
+ const [newNote, setNewNote] = useState('')
98
+ const [expanded, setExpanded] = useState(false)
99
+ const [isSubmitting, setIsSubmitting] = useState(false)
100
+
101
+ const handleAddNote = async () => {
102
+ if (!newNote.trim() || !onAddNote) return
103
+
104
+ setIsSubmitting(true)
105
+ try {
106
+ await onAddNote(newNote.trim())
107
+ setNewNote('')
108
+ } finally {
109
+ setIsSubmitting(false)
110
+ }
111
+ }
112
+
113
+ const handleKeyDown = (e: React.KeyboardEvent) => {
114
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
115
+ handleAddNote()
116
+ }
117
+ }
118
+
119
+ // Show only first N activities unless expanded
120
+ const visibleActivities = expanded ? activities : activities.slice(0, collapsedCount)
121
+ const hasMore = activities.length > collapsedCount
122
+
123
+ const isCurrentlySubmitting = submitting || isSubmitting
124
+
125
+ const DefaultLoading = (
126
+ <div className="flex items-center justify-center py-4">
127
+ <CircleNotch className="w-5 h-5 animate-spin text-muted-foreground" />
128
+ </div>
129
+ )
130
+
131
+ return (
132
+ <div className={`space-y-3 ${className}`}>
133
+ {/* Add Note Form - Compact inline */}
134
+ {showNoteInput && onAddNote && (
135
+ <div className="flex gap-2">
136
+ <Textarea
137
+ value={newNote}
138
+ onChange={(e) => setNewNote(e.target.value)}
139
+ onKeyDown={handleKeyDown}
140
+ placeholder={notePlaceholder}
141
+ rows={1}
142
+ className="resize-none min-h-[36px] py-2"
143
+ />
144
+ <Button
145
+ onClick={handleAddNote}
146
+ disabled={isCurrentlySubmitting || !newNote.trim()}
147
+ size="sm"
148
+ className="flex-shrink-0 h-9"
149
+ >
150
+ {isCurrentlySubmitting ? (
151
+ <CircleNotch className="w-4 h-4 animate-spin" />
152
+ ) : (
153
+ <PaperPlaneTilt className="w-4 h-4" weight="bold" />
154
+ )}
155
+ </Button>
156
+ </div>
157
+ )}
158
+
159
+ {/* Activity List - Minimal */}
160
+ {loading ? (
161
+ loadingComponent || DefaultLoading
162
+ ) : activities.length === 0 ? (
163
+ <p className="text-xs text-muted-foreground text-center py-2">
164
+ {emptyMessage}
165
+ </p>
166
+ ) : (
167
+ <div className="space-y-2">
168
+ {visibleActivities.map((activity) => {
169
+ const label = activityLabels[activity.activity_type] || ''
170
+ return (
171
+ <div key={activity.id} className="text-sm border-l-2 border-gray-200 pl-3 py-1">
172
+ <p className="text-gray-700">
173
+ {label && <span className="text-muted-foreground">{label} </span>}
174
+ {activity.content}
175
+ </p>
176
+ <p className="text-xs text-muted-foreground mt-0.5">
177
+ {activity.user} · {formatDate(activity.created_at)}
178
+ </p>
179
+ </div>
180
+ )
181
+ })}
182
+
183
+ {/* Show more/less toggle */}
184
+ {hasMore && (
185
+ <button
186
+ onClick={() => setExpanded(!expanded)}
187
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
188
+ >
189
+ {expanded ? (
190
+ <>
191
+ <CaretUp className="w-3 h-3" />
192
+ Show less
193
+ </>
194
+ ) : (
195
+ <>
196
+ <CaretDown className="w-3 h-3" />
197
+ Show {activities.length - collapsedCount} more
198
+ </>
199
+ )}
200
+ </button>
201
+ )}
202
+ </div>
203
+ )}
204
+ </div>
205
+ )
206
+ }
207
+
208
+ export default ActivityTimeline
@@ -0,0 +1,277 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+ import {
5
+ ReactFlow,
6
+ Node,
7
+ Edge,
8
+ Background,
9
+ useNodesState,
10
+ useEdgesState,
11
+ Position,
12
+ MarkerType,
13
+ Handle,
14
+ } from '@xyflow/react'
15
+ import '@xyflow/react/dist/style.css'
16
+
17
+ // ============================================
18
+ // Types
19
+ // ============================================
20
+
21
+ export interface N8nNode {
22
+ id: string
23
+ name: string
24
+ type: string
25
+ parameters?: Record<string, unknown>
26
+ }
27
+
28
+ export interface N8nWorkflow {
29
+ name: string
30
+ nodes: N8nNode[]
31
+ connections: Record<string, Record<string, Array<Array<{ node: string; type: string; index: number }>>>>
32
+ }
33
+
34
+ export interface WorkflowFlowProps {
35
+ /** The n8n workflow to visualize */
36
+ workflow: N8nWorkflow
37
+ /** Height of the flow diagram container */
38
+ height?: number
39
+ /** Additional CSS class name */
40
+ className?: string
41
+ }
42
+
43
+ // ============================================
44
+ // Helper Functions
45
+ // ============================================
46
+
47
+ function getNodeTypeLabel(type: string): string {
48
+ const labels: Record<string, string> = {
49
+ 'n8n-nodes-base.webhook': 'Webhook',
50
+ 'n8n-nodes-base.scheduleTrigger': 'Schedule',
51
+ 'n8n-nodes-base.if': 'Condition',
52
+ 'n8n-nodes-base.httpRequest': 'HTTP Request',
53
+ 'n8n-nodes-base.set': 'Set Data',
54
+ 'n8n-nodes-base.code': 'Code',
55
+ 'n8n-nodes-base.respondToWebhook': 'Response',
56
+ '@n8n/n8n-nodes-langchain.agent': 'AI Agent',
57
+ '@n8n/n8n-nodes-langchain.lmChatOpenAi': 'OpenAI',
58
+ '@n8n/n8n-nodes-langchain.lmChatAnthropic': 'Anthropic',
59
+ }
60
+ return labels[type] || type.split('.').pop()?.replace(/([A-Z])/g, ' $1').trim() || type
61
+ }
62
+
63
+ // ============================================
64
+ // Custom Node Component
65
+ // ============================================
66
+
67
+ function CustomNode({ data }: { data: { label: string; type: string } }) {
68
+ return (
69
+ <div className="px-3 py-2 rounded bg-white border border-slate-200 shadow-sm min-w-[110px] text-center">
70
+ <Handle type="target" position={Position.Left} className="!bg-slate-300 !w-1.5 !h-1.5 !border-0" />
71
+ <div className="text-xs font-medium text-slate-700 truncate max-w-[130px]">
72
+ {data.label}
73
+ </div>
74
+ <div className="text-[10px] text-slate-400 truncate max-w-[130px]">
75
+ {getNodeTypeLabel(data.type)}
76
+ </div>
77
+ <Handle type="source" position={Position.Right} className="!bg-slate-300 !w-1.5 !h-1.5 !border-0" />
78
+ </div>
79
+ )
80
+ }
81
+
82
+ const nodeTypes = { custom: CustomNode }
83
+
84
+ // ============================================
85
+ // Main Component
86
+ // ============================================
87
+
88
+ export function WorkflowFlow({ workflow, height = 350, className = '' }: WorkflowFlowProps) {
89
+ const { initialNodes, initialEdges } = useMemo(() => {
90
+ const n8nNodes = workflow.nodes || []
91
+ const connections = workflow.connections || {}
92
+ const nodeIdMap = new Map(n8nNodes.map(n => [n.name, n.id || n.name]))
93
+
94
+ // Build adjacency lists (forward and backward)
95
+ const forwardEdges = new Map<string, string[]>() // from -> [to]
96
+ const backwardEdges = new Map<string, string[]>() // to -> [from]
97
+ const allEdgeData: Array<{ from: string; to: string; outputType: string; oi: number; ci: number }> = []
98
+
99
+ Object.entries(connections).forEach(([fromNodeName, outputTypes]) => {
100
+ Object.entries(outputTypes).forEach(([outputType, outputs]) => {
101
+ if (!Array.isArray(outputs)) return
102
+ outputs.forEach((outputArray, oi) => {
103
+ if (!Array.isArray(outputArray)) return
104
+ outputArray.forEach((conn, ci) => {
105
+ if (!forwardEdges.has(fromNodeName)) forwardEdges.set(fromNodeName, [])
106
+ forwardEdges.get(fromNodeName)!.push(conn.node)
107
+
108
+ if (!backwardEdges.has(conn.node)) backwardEdges.set(conn.node, [])
109
+ backwardEdges.get(conn.node)!.push(fromNodeName)
110
+
111
+ allEdgeData.push({ from: fromNodeName, to: conn.node, outputType, oi, ci })
112
+ })
113
+ })
114
+ })
115
+ })
116
+
117
+ // Find trigger nodes (webhooks, schedule triggers) - these are the true starting points
118
+ const triggerNodes = n8nNodes.filter(n =>
119
+ n.type.includes('webhook') || n.type.includes('Trigger') || n.type.includes('schedule')
120
+ )
121
+
122
+ // If no triggers found, find nodes with no incoming edges as fallback
123
+ const roots = triggerNodes.length > 0
124
+ ? triggerNodes
125
+ : n8nNodes.filter(n => !backwardEdges.has(n.name) || backwardEdges.get(n.name)!.length === 0)
126
+
127
+ // Assign levels using BFS from trigger/root nodes only
128
+ const levels = new Map<string, number>()
129
+ const queue: string[] = []
130
+
131
+ roots.forEach(r => {
132
+ levels.set(r.name, 0)
133
+ queue.push(r.name)
134
+ })
135
+
136
+ const visited = new Set<string>()
137
+ while (queue.length > 0) {
138
+ const name = queue.shift()!
139
+ if (visited.has(name)) continue
140
+ visited.add(name)
141
+
142
+ const children = forwardEdges.get(name) || []
143
+ const myLevel = levels.get(name) || 0
144
+
145
+ children.forEach(child => {
146
+ const childLevel = levels.get(child)
147
+ if (childLevel === undefined || myLevel + 1 > childLevel) {
148
+ levels.set(child, myLevel + 1)
149
+ }
150
+ if (!visited.has(child)) {
151
+ queue.push(child)
152
+ }
153
+ })
154
+ }
155
+
156
+ // Handle nodes that weren't reached (like AI models that feed into other nodes)
157
+ // Place them one level before the node they connect to
158
+ n8nNodes.forEach(node => {
159
+ if (!levels.has(node.name)) {
160
+ const targets = forwardEdges.get(node.name) || []
161
+ if (targets.length > 0) {
162
+ // Find the level of the target and place this node one level before
163
+ const targetLevels = targets.map(t => levels.get(t) ?? 0)
164
+ const targetLevel = Math.min(...targetLevels)
165
+ levels.set(node.name, Math.max(0, targetLevel - 1))
166
+ } else {
167
+ // No connections at all, place at the end
168
+ const maxLevel = Math.max(0, ...Array.from(levels.values()))
169
+ levels.set(node.name, maxLevel)
170
+ }
171
+ }
172
+ })
173
+
174
+ // Group nodes by level
175
+ const nodesByLevel = new Map<number, string[]>()
176
+ levels.forEach((level, name) => {
177
+ if (!nodesByLevel.has(level)) nodesByLevel.set(level, [])
178
+ nodesByLevel.get(level)!.push(name)
179
+ })
180
+
181
+ // Sort nodes within each level to minimize crossings
182
+ // Simple heuristic: sort by average position of connected nodes in previous level
183
+ const sortedLevels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b)
184
+
185
+ sortedLevels.forEach((level, levelIdx) => {
186
+ if (levelIdx === 0) return // Skip first level
187
+
188
+ const nodesInLevel = nodesByLevel.get(level)!
189
+ const prevLevel = sortedLevels[levelIdx - 1]
190
+ const prevNodes = nodesByLevel.get(prevLevel) || []
191
+ const prevPositions = new Map(prevNodes.map((n, i) => [n, i]))
192
+
193
+ // Calculate average position of parents for each node
194
+ nodesInLevel.sort((a, b) => {
195
+ const parentsA = backwardEdges.get(a) || []
196
+ const parentsB = backwardEdges.get(b) || []
197
+
198
+ const avgA = parentsA.length > 0
199
+ ? parentsA.reduce((sum, p) => sum + (prevPositions.get(p) ?? 0), 0) / parentsA.length
200
+ : 0
201
+ const avgB = parentsB.length > 0
202
+ ? parentsB.reduce((sum, p) => sum + (prevPositions.get(p) ?? 0), 0) / parentsB.length
203
+ : 0
204
+
205
+ return avgA - avgB
206
+ })
207
+ })
208
+
209
+ // Assign positions
210
+ const positions = new Map<string, { x: number; y: number }>()
211
+ const xGap = 170
212
+ const yGap = 70
213
+
214
+ sortedLevels.forEach(level => {
215
+ const nodesInLevel = nodesByLevel.get(level)!
216
+ const totalHeight = (nodesInLevel.length - 1) * yGap
217
+ const startY = -totalHeight / 2
218
+
219
+ nodesInLevel.forEach((name, i) => {
220
+ positions.set(name, { x: level * xGap, y: startY + i * yGap })
221
+ })
222
+ })
223
+
224
+ // Create ReactFlow nodes
225
+ const nodes: Node[] = n8nNodes.map(node => ({
226
+ id: node.id || node.name,
227
+ type: 'custom',
228
+ position: positions.get(node.name) || { x: 0, y: 0 },
229
+ data: { label: node.name, type: node.type },
230
+ }))
231
+
232
+ // Create edges with better styling
233
+ const edges: Edge[] = allEdgeData.map(({ from, to, oi, ci }) => {
234
+ const fromId = nodeIdMap.get(from)!
235
+ const toId = nodeIdMap.get(to)!
236
+
237
+ return {
238
+ id: `${fromId}-${toId}-${oi}-${ci}`,
239
+ source: fromId,
240
+ target: toId,
241
+ type: 'smoothstep',
242
+ pathOptions: { borderRadius: 20 },
243
+ style: { stroke: '#94a3b8', strokeWidth: 1.5 },
244
+ markerEnd: { type: MarkerType.ArrowClosed, color: '#94a3b8', width: 14, height: 14 },
245
+ }
246
+ })
247
+
248
+ return { initialNodes: nodes, initialEdges: edges }
249
+ }, [workflow])
250
+
251
+ const [nodes, , onNodesChange] = useNodesState(initialNodes)
252
+ const [edges, , onEdgesChange] = useEdgesState(initialEdges)
253
+
254
+ return (
255
+ <div style={{ height }} className={`bg-slate-50 rounded-lg border border-slate-200 overflow-hidden ${className}`}>
256
+ <ReactFlow
257
+ nodes={nodes}
258
+ edges={edges}
259
+ onNodesChange={onNodesChange}
260
+ onEdgesChange={onEdgesChange}
261
+ nodeTypes={nodeTypes}
262
+ fitView
263
+ fitViewOptions={{ padding: 0.1, minZoom: 0.8 }}
264
+ minZoom={0.5}
265
+ maxZoom={2}
266
+ proOptions={{ hideAttribution: true }}
267
+ defaultEdgeOptions={{
268
+ type: 'smoothstep',
269
+ }}
270
+ >
271
+ <Background color="#e2e8f0" gap={24} size={1} />
272
+ </ReactFlow>
273
+ </div>
274
+ )
275
+ }
276
+
277
+ export default WorkflowFlow