feat: add Superfluid stream edges, 0xSplits view, and rVote API client
- StreamEdge component with animated dash pattern for Superfluid flow visualization (planned/active/paused status, flow rate + token labels) - SplitsView component showing recipient addresses, percentage bars, and export config - rVote API client module (fetchSpace, fetchPassedProposals) via CORS proxy - Stream-out handle on FunnelNode for creating stream connections - Splits and streams display in FunnelNode edit modal - Edge type system refactored to support allocation + stream edge data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f012502133
commit
8c3baed4b8
|
|
@ -19,8 +19,9 @@ import '@xyflow/react/dist/style.css'
|
||||||
import FunnelNode from './nodes/FunnelNode'
|
import FunnelNode from './nodes/FunnelNode'
|
||||||
import OutcomeNode from './nodes/OutcomeNode'
|
import OutcomeNode from './nodes/OutcomeNode'
|
||||||
import AllocationEdge from './edges/AllocationEdge'
|
import AllocationEdge from './edges/AllocationEdge'
|
||||||
|
import StreamEdge from './edges/StreamEdge'
|
||||||
import IntegrationPanel from './IntegrationPanel'
|
import IntegrationPanel from './IntegrationPanel'
|
||||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig } from '@/lib/types'
|
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
|
||||||
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
|
|
@ -30,6 +31,7 @@ const nodeTypes = {
|
||||||
|
|
||||||
const edgeTypes = {
|
const edgeTypes = {
|
||||||
allocation: AllocationEdge,
|
allocation: AllocationEdge,
|
||||||
|
stream: StreamEdge,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate edges with proportional Sankey-style widths
|
// Generate edges with proportional Sankey-style widths
|
||||||
|
|
@ -140,6 +142,36 @@ function generateEdges(
|
||||||
type: 'allocation',
|
type: 'allocation',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Stream edges (Superfluid visual planning)
|
||||||
|
data.streamAllocations?.forEach((stream) => {
|
||||||
|
const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e'
|
||||||
|
edges.push({
|
||||||
|
id: `stream-${node.id}-${stream.targetId}`,
|
||||||
|
source: node.id,
|
||||||
|
target: stream.targetId,
|
||||||
|
sourceHandle: 'stream-out',
|
||||||
|
animated: false,
|
||||||
|
style: {
|
||||||
|
stroke: statusColor,
|
||||||
|
strokeWidth: 3,
|
||||||
|
},
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
color: statusColor,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
flowRate: stream.flowRate,
|
||||||
|
tokenSymbol: stream.tokenSymbol,
|
||||||
|
status: stream.status,
|
||||||
|
sourceId: node.id,
|
||||||
|
targetId: stream.targetId,
|
||||||
|
},
|
||||||
|
type: 'stream',
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return edges
|
return edges
|
||||||
|
|
@ -230,6 +262,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
id: n.id,
|
id: n.id,
|
||||||
overflow: d.overflowAllocations,
|
overflow: d.overflowAllocations,
|
||||||
spending: d.spendingAllocations,
|
spending: d.spendingAllocations,
|
||||||
|
streams: d.streamAllocations,
|
||||||
rate: d.inflowRate,
|
rate: d.inflowRate,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -247,8 +280,9 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
|
|
||||||
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
||||||
const isSpending = params.sourceHandle === 'spending-out'
|
const isSpending = params.sourceHandle === 'spending-out'
|
||||||
|
const isStream = params.sourceHandle === 'stream-out'
|
||||||
|
|
||||||
if (!isOverflow && !isSpending) return
|
if (!isOverflow && !isSpending && !isStream) return
|
||||||
|
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== params.source || node.type !== 'funnel') return node
|
if (node.id !== params.source || node.type !== 'funnel') return node
|
||||||
|
|
@ -276,7 +310,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else if (isSpending) {
|
||||||
const existing = data.spendingAllocations || []
|
const existing = data.spendingAllocations || []
|
||||||
if (existing.some(a => a.targetId === params.target)) return node
|
if (existing.some(a => a.targetId === params.target)) return node
|
||||||
const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1))
|
const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1))
|
||||||
|
|
@ -298,6 +332,27 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
} else if (isStream) {
|
||||||
|
const existing = data.streamAllocations || []
|
||||||
|
if (existing.some(s => s.targetId === params.target)) return node
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
streamAllocations: [
|
||||||
|
...existing,
|
||||||
|
{
|
||||||
|
targetId: params.target!,
|
||||||
|
flowRate: 100,
|
||||||
|
tokenSymbol: 'DAIx',
|
||||||
|
status: 'planned' as const,
|
||||||
|
color: '#22c55e',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return node
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
@ -306,13 +361,31 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
|
|
||||||
const onReconnect = useCallback(
|
const onReconnect = useCallback(
|
||||||
(oldEdge: FlowEdge, newConnection: Connection) => {
|
(oldEdge: FlowEdge, newConnection: Connection) => {
|
||||||
const edgeData = oldEdge.data
|
const edgeData = oldEdge.data as AllocationEdgeData | undefined
|
||||||
if (!edgeData || !newConnection.target) return
|
if (!edgeData || !newConnection.target) return
|
||||||
|
|
||||||
const oldTargetId = oldEdge.target
|
const oldTargetId = oldEdge.target
|
||||||
const newTargetId = newConnection.target
|
const newTargetId = newConnection.target
|
||||||
if (oldTargetId === newTargetId) return
|
if (oldTargetId === newTargetId) return
|
||||||
|
|
||||||
|
// Stream edges: reconnect stream allocation
|
||||||
|
if (oldEdge.type === 'stream') {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== oldEdge.source || node.type !== 'funnel') return node
|
||||||
|
const data = node.data as FunnelNodeData
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
streamAllocations: (data.streamAllocations || []).map(s =>
|
||||||
|
s.targetId === oldTargetId ? { ...s, targetId: newTargetId } : s
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== oldEdge.source || node.type !== 'funnel') return node
|
if (node.id !== oldEdge.source || node.type !== 'funnel') return node
|
||||||
const data = node.data as FunnelNodeData
|
const data = node.data as FunnelNodeData
|
||||||
|
|
@ -353,7 +426,19 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
if (node.id !== edge.source || node.type !== 'funnel') return node
|
if (node.id !== edge.source || node.type !== 'funnel') return node
|
||||||
const data = node.data as FunnelNodeData
|
const data = node.data as FunnelNodeData
|
||||||
|
|
||||||
if (edge.data!.edgeType === 'overflow') {
|
// Stream edge removal
|
||||||
|
if (edge.type === 'stream') {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
streamAllocations: (data.streamAllocations || []).filter(s => s.targetId !== edge.target),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allocData = edge.data as AllocationEdgeData
|
||||||
|
if (allocData.edgeType === 'overflow') {
|
||||||
const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target)
|
const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target)
|
||||||
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||||
return {
|
return {
|
||||||
|
|
@ -564,6 +649,10 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||||
<span className="text-slate-600">Spending (bottom)</span>
|
<span className="text-slate-600">Spending (bottom)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500 border border-dashed border-green-700" />
|
||||||
|
<span className="text-slate-600">Stream (Superfluid)</span>
|
||||||
|
</div>
|
||||||
<div className="border-t border-slate-200 pt-1.5 mt-1.5">
|
<div className="border-t border-slate-200 pt-1.5 mt-1.5">
|
||||||
<span className="text-[10px] text-slate-400">Edge width = relative flow amount</span>
|
<span className="text-[10px] text-slate-400">Edge width = relative flow amount</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import { useState, useCallback } from 'react'
|
||||||
import type { FlowNode, IntegrationConfig } from '@/lib/types'
|
import type { FlowNode, IntegrationConfig } from '@/lib/types'
|
||||||
import { SUPPORTED_CHAINS, getBalances, detectSafeChains } from '@/lib/api/safe-client'
|
import { SUPPORTED_CHAINS, getBalances, detectSafeChains } from '@/lib/api/safe-client'
|
||||||
import type { SafeBalance, DetectedChain } from '@/lib/api/safe-client'
|
import type { SafeBalance, DetectedChain } from '@/lib/api/safe-client'
|
||||||
import { safeBalancesToFunnels } from '@/lib/integrations'
|
import { safeBalancesToFunnels, proposalsToOutcomes } from '@/lib/integrations'
|
||||||
|
import { fetchSpace, fetchPassedProposals } from '@/lib/api/rvote-client'
|
||||||
|
import type { RVoteProposal } from '@/lib/api/rvote-client'
|
||||||
|
|
||||||
interface IntegrationPanelProps {
|
interface IntegrationPanelProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
|
|
@ -37,6 +39,7 @@ export default function IntegrationPanel({
|
||||||
const [rvoteError, setRvoteError] = useState('')
|
const [rvoteError, setRvoteError] = useState('')
|
||||||
const [rvoteSpace, setRvoteSpace] = useState<{ name: string; memberCount: number } | null>(null)
|
const [rvoteSpace, setRvoteSpace] = useState<{ name: string; memberCount: number } | null>(null)
|
||||||
const [rvoteProposals, setRvoteProposals] = useState<Array<{ id: string; title: string; score: number }>>([])
|
const [rvoteProposals, setRvoteProposals] = useState<Array<{ id: string; title: string; score: number }>>([])
|
||||||
|
const [fetchedProposals, setFetchedProposals] = useState<RVoteProposal[]>([])
|
||||||
|
|
||||||
const handleFetchSafe = useCallback(async () => {
|
const handleFetchSafe = useCallback(async () => {
|
||||||
if (!safeAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
|
if (!safeAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
|
||||||
|
|
@ -109,32 +112,14 @@ export default function IntegrationPanel({
|
||||||
setRvoteError('')
|
setRvoteError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch via proxy to avoid CORS
|
const space = await fetchSpace(rvoteSlug)
|
||||||
const spaceRes = await fetch(`/api/proxy/rvote?endpoint=space&slug=${encodeURIComponent(rvoteSlug)}`)
|
|
||||||
if (!spaceRes.ok) {
|
|
||||||
setRvoteError(spaceRes.status === 404 ? 'Space not found' : 'Failed to fetch space')
|
|
||||||
setRvoteFetching(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const space = await spaceRes.json()
|
|
||||||
setRvoteSpace({ name: space.name, memberCount: space._count?.members || 0 })
|
setRvoteSpace({ name: space.name, memberCount: space._count?.members || 0 })
|
||||||
|
|
||||||
const proposalsRes = await fetch(
|
const proposals = await fetchPassedProposals(rvoteSlug)
|
||||||
`/api/proxy/rvote?endpoint=proposals&slug=${encodeURIComponent(rvoteSlug)}&status=PASSED`
|
setRvoteProposals(
|
||||||
|
proposals.map((p) => ({ id: p.id, title: p.title, score: p.score }))
|
||||||
)
|
)
|
||||||
if (proposalsRes.ok) {
|
setFetchedProposals(proposals)
|
||||||
const data = await proposalsRes.json()
|
|
||||||
const proposals = data.proposals || data.results || data || []
|
|
||||||
setRvoteProposals(
|
|
||||||
Array.isArray(proposals)
|
|
||||||
? proposals.map((p: { id: string; title: string; score: number }) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
score: p.score,
|
|
||||||
}))
|
|
||||||
: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
onIntegrationsChange({
|
onIntegrationsChange({
|
||||||
...integrations,
|
...integrations,
|
||||||
|
|
@ -151,29 +136,14 @@ export default function IntegrationPanel({
|
||||||
}
|
}
|
||||||
}, [rvoteSlug, integrations, onIntegrationsChange])
|
}, [rvoteSlug, integrations, onIntegrationsChange])
|
||||||
|
|
||||||
const handleImportRvote = useCallback(async () => {
|
const handleImportRvote = useCallback(() => {
|
||||||
if (rvoteProposals.length === 0) return
|
if (fetchedProposals.length === 0) return
|
||||||
|
const nodes = proposalsToOutcomes(fetchedProposals, rvoteSlug)
|
||||||
// Re-fetch full proposal data for import
|
if (nodes.length > 0) {
|
||||||
try {
|
onImportNodes(nodes)
|
||||||
const res = await fetch(
|
onClose()
|
||||||
`/api/proxy/rvote?endpoint=proposals&slug=${encodeURIComponent(rvoteSlug)}&status=PASSED`
|
|
||||||
)
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
const proposals = data.proposals || data.results || data || []
|
|
||||||
if (!Array.isArray(proposals)) return
|
|
||||||
|
|
||||||
const { proposalsToOutcomes } = await import('@/lib/integrations')
|
|
||||||
const nodes = proposalsToOutcomes(proposals, rvoteSlug)
|
|
||||||
if (nodes.length > 0) {
|
|
||||||
onImportNodes(nodes)
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setRvoteError('Failed to import proposals')
|
|
||||||
}
|
}
|
||||||
}, [rvoteProposals, rvoteSlug, onImportNodes, onClose])
|
}, [fetchedProposals, rvoteSlug, onImportNodes, onClose])
|
||||||
|
|
||||||
const toggleChain = (chainId: number) => {
|
const toggleChain = (chainId: number) => {
|
||||||
setSelectedChains((prev) =>
|
setSelectedChains((prev) =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import type { SplitsConfig } from '@/lib/types'
|
||||||
|
|
||||||
|
interface SplitsViewProps {
|
||||||
|
config: SplitsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAIN_NAMES: Record<number, string> = {
|
||||||
|
100: 'Gnosis',
|
||||||
|
10: 'Optimism',
|
||||||
|
1: 'Ethereum',
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateAddress(addr: string): string {
|
||||||
|
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAR_COLORS = [
|
||||||
|
'#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981',
|
||||||
|
'#f59e0b', '#ef4444', '#6366f1', '#14b8a6', '#f97316',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function SplitsView({ config }: SplitsViewProps) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
const exportData = {
|
||||||
|
type: '0xSplits',
|
||||||
|
chainId: config.chainId,
|
||||||
|
distributorFee: config.distributorFee,
|
||||||
|
recipients: config.recipients.map((r) => ({
|
||||||
|
address: r.address,
|
||||||
|
percentAllocation: r.percentage * 10000, // 0xSplits uses basis points (ppm)
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(exportData, null, 2)).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
})
|
||||||
|
}, [config])
|
||||||
|
|
||||||
|
const totalPct = config.recipients.reduce((s, r) => s + r.percentage, 0)
|
||||||
|
const chainName = CHAIN_NAMES[config.chainId] || `Chain ${config.chainId}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-slate-700 uppercase tracking-wide">
|
||||||
|
0xSplits Config
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 bg-slate-100 text-slate-500 rounded">
|
||||||
|
{chainName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-400">
|
||||||
|
Fee: {config.distributorFee}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Combined percentage bar */}
|
||||||
|
<div className="h-3 bg-slate-100 rounded-full overflow-hidden flex">
|
||||||
|
{config.recipients.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={r.address}
|
||||||
|
className="h-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${(r.percentage / totalPct) * 100}%`,
|
||||||
|
backgroundColor: BAR_COLORS[i % BAR_COLORS.length],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipient list */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{config.recipients.map((r, i) => (
|
||||||
|
<div key={r.address} className="flex items-center gap-2 text-xs">
|
||||||
|
<div
|
||||||
|
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||||
|
style={{ backgroundColor: BAR_COLORS[i % BAR_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-slate-600 flex-1 truncate" title={r.address}>
|
||||||
|
{r.label || truncateAddress(r.address)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-medium text-slate-800">{r.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPct !== 100 && (
|
||||||
|
<p className="text-[10px] text-amber-600">
|
||||||
|
Total is {totalPct}% (should be 100%)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg text-xs font-medium border border-slate-300 text-slate-600 hover:bg-slate-50 transition-all"
|
||||||
|
>
|
||||||
|
{copied ? 'Copied to clipboard!' : 'Export Config JSON'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { FlowEdgeData } from '@/lib/types'
|
import type { AllocationEdgeData } from '@/lib/types'
|
||||||
|
|
||||||
export default function AllocationEdge({
|
export default function AllocationEdge({
|
||||||
id,
|
id,
|
||||||
|
|
@ -21,7 +21,7 @@ export default function AllocationEdge({
|
||||||
style,
|
style,
|
||||||
markerEnd,
|
markerEnd,
|
||||||
}: EdgeProps) {
|
}: EdgeProps) {
|
||||||
const edgeData = data as FlowEdgeData | undefined
|
const edgeData = data as AllocationEdgeData | undefined
|
||||||
|
|
||||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||||
sourceX,
|
sourceX,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSmoothStepPath,
|
||||||
|
EdgeLabelRenderer,
|
||||||
|
BaseEdge,
|
||||||
|
type EdgeProps,
|
||||||
|
} from '@xyflow/react'
|
||||||
|
import type { StreamEdgeData } from '@/lib/types'
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, { stroke: string; bg: string; text: string; label: string }> = {
|
||||||
|
planned: { stroke: '#22c55e', bg: 'rgba(34,197,94,0.12)', text: '#16a34a', label: 'Planned' },
|
||||||
|
active: { stroke: '#3b82f6', bg: 'rgba(59,130,246,0.12)', text: '#2563eb', label: 'Active' },
|
||||||
|
paused: { stroke: '#f97316', bg: 'rgba(249,115,22,0.12)', text: '#ea580c', label: 'Paused' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StreamEdge({
|
||||||
|
id,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
data,
|
||||||
|
markerEnd,
|
||||||
|
}: EdgeProps) {
|
||||||
|
const edgeData = data as StreamEdgeData | undefined
|
||||||
|
const status = edgeData?.status ?? 'planned'
|
||||||
|
const flowRate = edgeData?.flowRate ?? 0
|
||||||
|
const tokenSymbol = edgeData?.tokenSymbol ?? 'DAIx'
|
||||||
|
const colors = STATUS_COLORS[status] || STATUS_COLORS.planned
|
||||||
|
|
||||||
|
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Animated dashed line to simulate flowing tokens
|
||||||
|
const isPaused = status === 'paused'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Background glow path */}
|
||||||
|
<path
|
||||||
|
d={edgePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.stroke}
|
||||||
|
strokeWidth={8}
|
||||||
|
strokeOpacity={0.15}
|
||||||
|
/>
|
||||||
|
{/* Main path with animated dash */}
|
||||||
|
<BaseEdge
|
||||||
|
id={id}
|
||||||
|
path={edgePath}
|
||||||
|
markerEnd={markerEnd}
|
||||||
|
style={{
|
||||||
|
stroke: colors.stroke,
|
||||||
|
strokeWidth: 3,
|
||||||
|
strokeDasharray: '8 4',
|
||||||
|
animation: isPaused ? 'none' : 'streamFlow 1s linear infinite',
|
||||||
|
opacity: isPaused ? 0.5 : 0.9,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Inject animation keyframes */}
|
||||||
|
<foreignObject width={0} height={0}>
|
||||||
|
<style>{`
|
||||||
|
@keyframes streamFlow {
|
||||||
|
to { stroke-dashoffset: -12; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</foreignObject>
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
className="nodrag nopan"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||||
|
pointerEvents: 'all',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Flow rate label */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colors.text,
|
||||||
|
background: 'rgba(255,255,255,0.95)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${colors.stroke}`,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flowRate.toLocaleString()} {tokenSymbol}/mo
|
||||||
|
</span>
|
||||||
|
{/* Status pill */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
color: colors.text,
|
||||||
|
background: colors.bg,
|
||||||
|
padding: '1px 5px',
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{colors.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { memo, useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
||||||
import type { NodeProps } from '@xyflow/react'
|
import type { NodeProps } from '@xyflow/react'
|
||||||
import type { FunnelNodeData, OutcomeNodeData } from '@/lib/types'
|
import type { FunnelNodeData, OutcomeNodeData } from '@/lib/types'
|
||||||
|
import SplitsView from '../SplitsView'
|
||||||
|
|
||||||
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
||||||
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||||
|
|
@ -597,6 +598,15 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
id="spending-out"
|
id="spending-out"
|
||||||
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-bottom-2"
|
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-bottom-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Stream handle (Superfluid) - right side, lower */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="stream-out"
|
||||||
|
className="!w-3.5 !h-3.5 !bg-green-500 !border-2 !border-white !rounded-full"
|
||||||
|
style={{ top: '65%' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
|
|
@ -826,6 +836,36 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{nodeData.splitsConfig && (
|
||||||
|
<div className="mt-4 p-4 bg-slate-50 rounded-xl">
|
||||||
|
<SplitsView config={nodeData.splitsConfig} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nodeData.streamAllocations && nodeData.streamAllocations.length > 0 && (
|
||||||
|
<div className="mt-4 p-4 bg-green-50 rounded-xl">
|
||||||
|
<span className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-2">
|
||||||
|
Superfluid Streams
|
||||||
|
</span>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{nodeData.streamAllocations.map((stream, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
stream.status === 'active' ? 'bg-blue-500' :
|
||||||
|
stream.status === 'paused' ? 'bg-orange-500' : 'bg-green-500'
|
||||||
|
}`} />
|
||||||
|
<span className="text-slate-600">{stream.targetId}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-green-700">
|
||||||
|
{stream.flowRate.toLocaleString()} {stream.tokenSymbol}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-center text-[10px] text-slate-400 mt-4">
|
<p className="text-center text-[10px] text-slate-400 mt-4">
|
||||||
Drag pie slices to adjust • Click + to add new items
|
Drag pie slices to adjust • Click + to add new items
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
export interface RVoteSpace {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
_count?: { members: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RVoteProposal {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
score: number
|
||||||
|
status: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROXY_BASE = '/api/proxy/rvote'
|
||||||
|
|
||||||
|
export async function fetchSpace(slug: string): Promise<RVoteSpace> {
|
||||||
|
const res = await fetch(`${PROXY_BASE}?endpoint=space&slug=${encodeURIComponent(slug)}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) throw new Error('Space not found')
|
||||||
|
throw new Error(`Failed to fetch space: ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPassedProposals(slug: string): Promise<RVoteProposal[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${PROXY_BASE}?endpoint=proposals&slug=${encodeURIComponent(slug)}&status=PASSED`
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch proposals: ${res.status}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
const proposals = data.proposals || data.results || data || []
|
||||||
|
return Array.isArray(proposals) ? proposals : []
|
||||||
|
}
|
||||||
|
|
@ -57,11 +57,11 @@ export function safeBalancesToFunnels(
|
||||||
export interface RVoteProposal {
|
export interface RVoteProposal {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description?: string
|
||||||
status: string
|
status: string
|
||||||
score: number
|
score: number
|
||||||
author?: { id: string; name: string | null }
|
author?: { id: string; name: string | null }
|
||||||
createdAt: string
|
createdAt?: string
|
||||||
_count?: { votes: number; finalVotes: number }
|
_count?: { votes: number; finalVotes: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
13
lib/types.ts
13
lib/types.ts
|
|
@ -105,7 +105,7 @@ export interface OutcomeNodeData {
|
||||||
|
|
||||||
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData>
|
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData>
|
||||||
|
|
||||||
export interface FlowEdgeData {
|
export interface AllocationEdgeData {
|
||||||
allocation: number // percentage 0-100
|
allocation: number // percentage 0-100
|
||||||
color: string
|
color: string
|
||||||
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
|
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
|
||||||
|
|
@ -116,6 +116,17 @@ export interface FlowEdgeData {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StreamEdgeData {
|
||||||
|
flowRate: number
|
||||||
|
tokenSymbol: string
|
||||||
|
status: 'planned' | 'active' | 'paused'
|
||||||
|
sourceId: string
|
||||||
|
targetId: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlowEdgeData = AllocationEdgeData | StreamEdgeData
|
||||||
|
|
||||||
export type FlowEdge = Edge<FlowEdgeData>
|
export type FlowEdge = Edge<FlowEdgeData>
|
||||||
|
|
||||||
// Serializable space config (no xyflow internals)
|
// Serializable space config (no xyflow internals)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue