Merge pull request #31 from johndoe6345789/codex/split-featureideacloud-into-subcomponents

Refactor FeatureIdeaCloud into subcomponents and JSON seed data
This commit is contained in:
2026-01-18 00:22:58 +00:00
committed by GitHub
14 changed files with 1880 additions and 616 deletions

View File

@@ -691,594 +691,52 @@ export function FeatureIdeaCloud() {
return (
<div className="h-full flex flex-col bg-gradient-to-br from-background via-muted/20 to-background">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChangeWrapper}
onEdgesChange={onEdgesChangeWrapper}
onConnect={onConnect}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onEdgeClick={onEdgeClick}
onNodeDoubleClick={onNodeDoubleClick}
nodeTypes={nodeTypes}
connectionMode={ConnectionMode.Loose}
reconnectRadius={20}
fitView
minZoom={0.2}
maxZoom={2}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: 'default',
animated: false,
}}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="hsl(var(--border))" />
<Controls showInteractive={false} />
<FeatureIdeaCanvas
nodes={cloud.nodes}
edges={cloud.edges}
onNodesChangeWrapper={cloud.onNodesChangeWrapper}
onEdgesChangeWrapper={cloud.onEdgesChangeWrapper}
onConnect={cloud.onConnect}
onReconnect={cloud.onReconnect}
onReconnectStart={cloud.onReconnectStart}
onReconnectEnd={cloud.onReconnectEnd}
onEdgeClick={cloud.onEdgeClick}
onNodeDoubleClick={cloud.onNodeDoubleClick}
debugPanelOpen={cloud.debugPanelOpen}
setDebugPanelOpen={cloud.setDebugPanelOpen}
handleAddIdea={cloud.handleAddIdea}
handleAddGroup={cloud.handleAddGroup}
handleGenerateIdeas={cloud.handleGenerateIdeas}
safeIdeas={cloud.safeIdeas}
/>
<Panel position="top-right" className="flex gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setDebugPanelOpen(!debugPanelOpen)}
variant="outline"
className="shadow-lg"
size="icon"
>
🔍
</Button>
</TooltipTrigger>
<TooltipContent>Debug Connection Status</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleGenerateIdeas} variant="outline" className="shadow-lg">
<Sparkle size={20} weight="duotone" />
</Button>
</TooltipTrigger>
<TooltipContent>AI Generate Ideas</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleAddGroup} variant="outline" className="shadow-lg">
<Package size={20} />
</Button>
</TooltipTrigger>
<TooltipContent>Add Group</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleAddIdea} className="shadow-lg">
<Plus size={20} />
</Button>
</TooltipTrigger>
<TooltipContent>Add Idea</TooltipContent>
</Tooltip>
</TooltipProvider>
</Panel>
<Panel position="bottom-right">
<div className="bg-card border border-border rounded-lg shadow-lg p-2 text-xs text-muted-foreground max-w-sm">
<p className="mb-1">💡 <strong>Tip:</strong> Double-click ideas to view details</p>
<p className="mb-1">📦 Create groups to organize related ideas</p>
<p className="mb-1">🔗 Drag from handles on card edges to connect ideas</p>
<p className="mb-1"> Drag existing connection ends to remap them</p>
<p> Click connections to edit or delete them</p>
</div>
</Panel>
{debugPanelOpen && (
<Panel position="top-center" className="max-w-2xl">
<Card className="shadow-2xl border-2">
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm">🔍 Connection Debug Panel</h3>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setDebugPanelOpen(false)}
>
</Button>
</div>
<div className="grid grid-cols-3 gap-4 p-3 bg-muted/50 rounded-lg text-xs">
<div>
<div className="font-semibold text-foreground mb-1">Total Edges</div>
<div className="text-2xl font-bold text-primary">{edges.length}</div>
</div>
<div>
<div className="font-semibold text-foreground mb-1">Total Nodes</div>
<div className="text-2xl font-bold text-accent">{nodes.length}</div>
</div>
<div>
<div className="font-semibold text-foreground mb-1">Total Ideas</div>
<div className="text-2xl font-bold text-secondary">{safeIdeas.length}</div>
</div>
</div>
<div className="space-y-2">
<div className="font-semibold text-xs flex items-center justify-between">
<span>Connection Matrix (Handle Occupancy)</span>
<Badge variant="outline" className="text-xs">1:1 Constraint Active</Badge>
</div>
<ScrollArea className="h-48 w-full rounded-md border">
<div className="p-2 space-y-2 text-xs font-mono">
{safeIdeas.slice(0, 10).map((idea) => {
const nodeEdges = edges.filter(e => e.source === idea.id || e.target === idea.id)
const leftHandles = edges.filter(e => e.target === idea.id && e.targetHandle?.startsWith('left'))
const rightHandles = edges.filter(e => e.source === idea.id && e.sourceHandle?.startsWith('right'))
const topHandles = edges.filter(e => e.target === idea.id && e.targetHandle?.startsWith('top'))
const bottomHandles = edges.filter(e => e.source === idea.id && e.sourceHandle?.startsWith('bottom'))
const leftUnique = new Set(leftHandles.map(e => e.targetHandle)).size
const rightUnique = new Set(rightHandles.map(e => e.sourceHandle)).size
const topUnique = new Set(topHandles.map(e => e.targetHandle)).size
const bottomUnique = new Set(bottomHandles.map(e => e.sourceHandle)).size
const hasViolation = leftHandles.length !== leftUnique || rightHandles.length !== rightUnique ||
topHandles.length !== topUnique || bottomHandles.length !== bottomUnique
return (
<div key={idea.id} className={`p-2 rounded border ${hasViolation ? 'bg-red-500/20 border-red-500' : 'bg-muted/30'}`}>
<div className="font-semibold truncate mb-1 flex items-center gap-2" title={idea.title}>
{hasViolation && <span className="text-red-500"></span>}
{idea.title}
</div>
<div className="grid grid-cols-4 gap-1 text-[10px]">
<div className={`p-1 rounded text-center ${
leftHandles.length !== leftUnique ? 'bg-red-500/40 text-red-900 dark:text-red-100 font-bold' :
leftHandles.length >= 1 ? 'bg-green-500/20 text-green-700 dark:text-green-300' : 'bg-muted'
}`}>
{leftHandles.length}/{leftUnique} {leftHandles.length !== leftUnique ? '⚠️' : leftHandles.length > 0 ? '✓' : '○'}
</div>
<div className={`p-1 rounded text-center ${
rightHandles.length !== rightUnique ? 'bg-red-500/40 text-red-900 dark:text-red-100 font-bold' :
rightHandles.length >= 1 ? 'bg-green-500/20 text-green-700 dark:text-green-300' : 'bg-muted'
}`}>
{rightHandles.length}/{rightUnique} {rightHandles.length !== rightUnique ? '⚠️' : rightHandles.length > 0 ? '<27><><EFBFBD><EFBFBD><EFBFBD>' : '○'}
</div>
<div className={`p-1 rounded text-center ${
topHandles.length !== topUnique ? 'bg-red-500/40 text-red-900 dark:text-red-100 font-bold' :
topHandles.length >= 1 ? 'bg-green-500/20 text-green-700 dark:text-green-300' : 'bg-muted'
}`}>
{topHandles.length}/{topUnique} {topHandles.length !== topUnique ? '⚠️' : topHandles.length > 0 ? '✓' : '○'}
</div>
<div className={`p-1 rounded text-center ${
bottomHandles.length !== bottomUnique ? 'bg-red-500/40 text-red-900 dark:text-red-100 font-bold' :
bottomHandles.length >= 1 ? 'bg-green-500/20 text-green-700 dark:text-green-300' : 'bg-muted'
}`}>
{bottomHandles.length}/{bottomUnique} {bottomHandles.length !== bottomUnique ? '⚠️' : bottomHandles.length > 0 ? '✓' : '○'}
</div>
</div>
<div className="mt-1 text-[10px] text-muted-foreground">
Total: {nodeEdges.length} connection{nodeEdges.length !== 1 ? 's' : ''}, Handles: L{leftUnique}|R{rightUnique}|T{topUnique}|B{bottomUnique}
</div>
</div>
)
})}
{safeIdeas.length > 10 && (
<div className="text-center text-muted-foreground py-2">
... and {safeIdeas.length - 10} more ideas
</div>
)}
</div>
</ScrollArea>
</div>
{(() => {
const violations: string[] = []
safeIdeas.forEach(idea => {
const leftHandles = edges.filter(e => e.target === idea.id && e.targetHandle?.startsWith('left'))
const rightHandles = edges.filter(e => e.source === idea.id && e.sourceHandle?.startsWith('right'))
const topHandles = edges.filter(e => e.target === idea.id && e.targetHandle?.startsWith('top'))
const bottomHandles = edges.filter(e => e.source === idea.id && e.sourceHandle?.startsWith('bottom'))
const leftUnique = new Set(leftHandles.map(e => e.targetHandle)).size
const rightUnique = new Set(rightHandles.map(e => e.sourceHandle)).size
const topUnique = new Set(topHandles.map(e => e.targetHandle)).size
const bottomUnique = new Set(bottomHandles.map(e => e.sourceHandle)).size
if (leftHandles.length !== leftUnique) violations.push(`${idea.title}: Left side has duplicate handle usage (${leftHandles.length} connections on ${leftUnique} handles)`)
if (rightHandles.length !== rightUnique) violations.push(`${idea.title}: Right side has duplicate handle usage (${rightHandles.length} connections on ${rightUnique} handles)`)
if (topHandles.length !== topUnique) violations.push(`${idea.title}: Top side has duplicate handle usage (${topHandles.length} connections on ${topUnique} handles)`)
if (bottomHandles.length !== bottomUnique) violations.push(`${idea.title}: Bottom side has duplicate handle usage (${bottomHandles.length} connections on ${bottomUnique} handles)`)
})
return violations.length > 0 ? (
<div className="space-y-1 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div className="font-semibold text-xs text-red-700 dark:text-red-300 flex items-center gap-1">
CONSTRAINT VIOLATIONS DETECTED
</div>
<div className="text-xs space-y-0.5 text-red-600 dark:text-red-400">
{violations.map((v, i) => (
<div key={i}> {v}</div>
))}
</div>
</div>
) : (
<div className="space-y-1 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<div className="font-semibold text-xs text-green-700 dark:text-green-300 flex items-center gap-1">
All Constraints Satisfied
</div>
<div className="text-xs space-y-0.5">
<div> Each handle has exactly 1 connection (1:1 mapping) </div>
<div> New connections automatically remove conflicts </div>
<div> Spare blank handles always available </div>
<div> Remapping preserves 1:1 constraint </div>
<div> Changes persist to database immediately </div>
</div>
</div>
)
})()}
</div>
</Card>
</Panel>
)}
</ReactFlow>
<Dialog open={groupDialogOpen} onOpenChange={setGroupDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{selectedGroup?.label ? 'Edit Group' : 'New Group'}
</DialogTitle>
<DialogDescription>
Create a container to organize related ideas
</DialogDescription>
</DialogHeader>
{selectedGroup && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">Group Name</label>
<Input
value={selectedGroup.label}
onChange={(e) => setSelectedGroup({ ...selectedGroup, label: e.target.value })}
placeholder="e.g., Authentication Features"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Color</label>
<div className="grid grid-cols-4 gap-2">
{GROUP_COLORS.map(color => (
<button
key={color.value}
onClick={() => setSelectedGroup({ ...selectedGroup, color: color.value })}
className={`h-12 rounded-lg border-2 transition-all hover:scale-105 ${
selectedGroup.color === color.value
? 'border-foreground ring-2 ring-primary'
: 'border-border'
}`}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
<div className="p-3 bg-muted rounded-lg text-sm">
<p className="font-medium mb-1">💡 Tips:</p>
<ul className="space-y-1 text-xs text-muted-foreground">
<li> Groups provide visual organization for related ideas</li>
<li> Drag ideas into groups or assign them in the idea editor</li>
<li> Ideas stay within their group boundaries when moved</li>
</ul>
</div>
</div>
)}
<DialogFooter>
<div className="flex justify-between w-full">
<div>
{selectedGroup && groups?.find(g => g.id === selectedGroup.id) && (
<Button
variant="destructive"
onClick={() => handleDeleteGroup(selectedGroup.id)}
>
<Trash size={16} className="mr-2" />
Delete
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setGroupDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveGroup}>
Save Group
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{selectedIdea?.title ? 'Edit Idea' : 'New Idea'}
</DialogTitle>
<DialogDescription>
Create or modify a feature idea for your app
</DialogDescription>
</DialogHeader>
{selectedIdea && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">Title</label>
<Input
value={selectedIdea.title}
onChange={(e) => setSelectedIdea({ ...selectedIdea, title: e.target.value })}
placeholder="Feature name..."
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Description</label>
<Textarea
value={selectedIdea.description}
onChange={(e) => setSelectedIdea({ ...selectedIdea, description: e.target.value })}
placeholder="Describe the feature..."
rows={4}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Category</label>
<select
value={selectedIdea.category}
onChange={(e) => setSelectedIdea({ ...selectedIdea, category: e.target.value })}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
{CATEGORIES.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Priority</label>
<select
value={selectedIdea.priority}
onChange={(e) => setSelectedIdea({ ...selectedIdea, priority: e.target.value as any })}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
{PRIORITIES.map(priority => (
<option key={priority} value={priority}>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Status</label>
<select
value={selectedIdea.status}
onChange={(e) => setSelectedIdea({ ...selectedIdea, status: e.target.value as any })}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
{STATUSES.map(status => (
<option key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' ')}
</option>
))}
</select>
</div>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Group</label>
<select
value={selectedIdea.parentGroup || ''}
onChange={(e) => setSelectedIdea({ ...selectedIdea, parentGroup: e.target.value || undefined })}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">No group</option>
{safeGroups.map(group => (
<option key={group.id} value={group.id}>
{group.label}
</option>
))}
</select>
</div>
</div>
)}
<DialogFooter>
<div className="flex justify-between w-full">
<div>
{selectedIdea && ideas?.find(i => i.id === selectedIdea.id) && (
<Button
variant="destructive"
onClick={() => handleDeleteIdea(selectedIdea.id)}
>
<Trash size={16} className="mr-2" />
Delete
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveIdea}>
Save Idea
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={viewDialogOpen} onOpenChange={setViewDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedIdea?.title}</DialogTitle>
</DialogHeader>
{selectedIdea && (
<div className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">{selectedIdea.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-muted-foreground">Category</label>
<p className="text-sm font-medium">{selectedIdea.category}</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Priority</label>
<p className="text-sm font-medium capitalize">{selectedIdea.priority}</p>
</div>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Status</label>
<Badge className={`${STATUS_COLORS[selectedIdea.status]} mt-1`}>
{selectedIdea.status}
</Badge>
</div>
{selectedIdea.parentGroup && (
<div>
<label className="text-xs font-medium text-muted-foreground">Group</label>
<p className="text-sm font-medium">
{safeGroups.find(g => g.id === selectedIdea.parentGroup)?.label || 'Unknown'}
</p>
</div>
)}
<div>
<label className="text-xs font-medium text-muted-foreground">Created</label>
<p className="text-sm">{new Date(selectedIdea.createdAt).toLocaleDateString()}</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-2 block">Connections</label>
<div className="space-y-2">
{edges
.filter(e => e.source === selectedIdea.id || e.target === selectedIdea.id)
.map(edge => {
const otherIdeaId = edge.source === selectedIdea.id ? edge.target : edge.source
const otherIdea = safeIdeas.find(i => i.id === otherIdeaId)
const isOutgoing = edge.source === selectedIdea.id
return (
<div key={edge.id} className="flex items-center gap-2 text-sm p-2 bg-muted rounded">
<span className="flex-1">
{isOutgoing ? '→' : '←'} {otherIdea?.title || 'Unknown'}
</span>
{edge.data?.label && (
<Badge variant="outline" className="text-xs">{edge.data.label}</Badge>
)}
</div>
)
})}
{edges.filter(e => e.source === selectedIdea.id || e.target === selectedIdea.id).length === 0 && (
<p className="text-sm text-muted-foreground">No connections</p>
)}
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setViewDialogOpen(false)}>
Close
</Button>
<Button onClick={() => {
setViewDialogOpen(false)
setEditDialogOpen(true)
}}>
Edit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={edgeDialogOpen} onOpenChange={setEdgeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Connection Details</DialogTitle>
<DialogDescription>
Manage the relationship between ideas
</DialogDescription>
</DialogHeader>
{selectedEdge && selectedEdge.data && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-muted-foreground">From</label>
<p className="text-sm font-medium">
{safeIdeas.find(i => i.id === selectedEdge.source)?.title}
</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">To</label>
<p className="text-sm font-medium">
{safeIdeas.find(i => i.id === selectedEdge.target)?.title}
</p>
</div>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Label</label>
<Input
value={selectedEdge.data?.label || ''}
onChange={(e) => setSelectedEdge({
...selectedEdge,
data: {
...selectedEdge.data!,
label: e.target.value
}
})}
placeholder="relates to"
/>
</div>
<div className="p-3 bg-muted rounded-lg text-sm">
<p className="font-medium mb-2">💡 Connection Info:</p>
<p className="text-xs text-muted-foreground">
Each connection point can have exactly one arrow. Creating a new connection from an occupied point will automatically remap the existing connection.
</p>
</div>
</div>
)}
<DialogFooter>
<div className="flex justify-between w-full">
<Button
variant="destructive"
onClick={() => selectedEdge && handleDeleteEdge(selectedEdge.id)}
>
<Trash size={16} className="mr-2" />
Delete
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setEdgeDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveEdge}>
Save
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<FeatureIdeaDialogs
ideas={cloud.ideas}
groups={cloud.groups}
safeIdeas={cloud.safeIdeas}
safeGroups={cloud.safeGroups}
edges={cloud.edges}
selectedIdea={cloud.selectedIdea}
selectedGroup={cloud.selectedGroup}
selectedEdge={cloud.selectedEdge}
editDialogOpen={cloud.editDialogOpen}
groupDialogOpen={cloud.groupDialogOpen}
viewDialogOpen={cloud.viewDialogOpen}
edgeDialogOpen={cloud.edgeDialogOpen}
setSelectedIdea={cloud.setSelectedIdea}
setSelectedGroup={cloud.setSelectedGroup}
setSelectedEdge={cloud.setSelectedEdge}
setEditDialogOpen={cloud.setEditDialogOpen}
setGroupDialogOpen={cloud.setGroupDialogOpen}
setViewDialogOpen={cloud.setViewDialogOpen}
setEdgeDialogOpen={cloud.setEdgeDialogOpen}
handleSaveIdea={cloud.handleSaveIdea}
handleDeleteIdea={cloud.handleDeleteIdea}
handleSaveGroup={cloud.handleSaveGroup}
handleDeleteGroup={cloud.handleDeleteGroup}
handleDeleteEdge={cloud.handleDeleteEdge}
handleSaveEdge={cloud.handleSaveEdge}
/>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { MouseEvent as ReactMouseEvent } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
ConnectionMode,
Controls,
Connection,
Edge,
Node,
Panel,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { FeatureIdea, IdeaEdgeData } from './types'
import { nodeTypes } from './nodes'
import { FeatureIdeaDebugPanel } from './FeatureIdeaDebugPanel'
import { FeatureIdeaTipsPanel } from './FeatureIdeaTipsPanel'
import { FeatureIdeaToolbar } from './FeatureIdeaToolbar'
type FeatureIdeaCanvasProps = {
nodes: Node[]
edges: Edge<IdeaEdgeData>[]
onNodesChangeWrapper: (changes: any) => void
onEdgesChangeWrapper: (changes: any) => void
onConnect: (connection: Connection) => void
onReconnect: (oldEdge: Edge, newConnection: Connection) => void
onReconnectStart: () => void
onReconnectEnd: (_: MouseEvent | TouchEvent, edge: Edge) => void
onEdgeClick: (event: ReactMouseEvent, edge: Edge<IdeaEdgeData>) => void
onNodeDoubleClick: (event: ReactMouseEvent, node: Node<FeatureIdea>) => void
debugPanelOpen: boolean
setDebugPanelOpen: (open: boolean) => void
handleAddIdea: () => void
handleAddGroup: () => void
handleGenerateIdeas: () => void
safeIdeas: FeatureIdea[]
}
export const FeatureIdeaCanvas = ({
nodes,
edges,
onNodesChangeWrapper,
onEdgesChangeWrapper,
onConnect,
onReconnect,
onReconnectStart,
onReconnectEnd,
onEdgeClick,
onNodeDoubleClick,
debugPanelOpen,
setDebugPanelOpen,
handleAddIdea,
handleAddGroup,
handleGenerateIdeas,
safeIdeas,
}: FeatureIdeaCanvasProps) => (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChangeWrapper}
onEdgesChange={onEdgesChangeWrapper}
onConnect={onConnect}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onEdgeClick={onEdgeClick}
onNodeDoubleClick={onNodeDoubleClick}
nodeTypes={nodeTypes}
connectionMode={ConnectionMode.Loose}
reconnectRadius={20}
fitView
minZoom={0.2}
maxZoom={2}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: 'default',
animated: false,
}}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="hsl(var(--border))" />
<Controls showInteractive={false} />
<FeatureIdeaToolbar
debugPanelOpen={debugPanelOpen}
setDebugPanelOpen={setDebugPanelOpen}
handleGenerateIdeas={handleGenerateIdeas}
handleAddGroup={handleAddGroup}
handleAddIdea={handleAddIdea}
/>
<FeatureIdeaTipsPanel />
{debugPanelOpen && (
<Panel position="top-center" className="max-w-2xl">
<FeatureIdeaDebugPanel nodes={nodes} edges={edges} safeIdeas={safeIdeas} onClose={() => setDebugPanelOpen(false)} />
</Panel>
)}
</ReactFlow>
)

View File

@@ -0,0 +1,219 @@
import { Edge, Node } from 'reactflow'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import { FeatureIdea, IdeaEdgeData } from './types'
type FeatureIdeaDebugPanelProps = {
nodes: Node[]
edges: Edge<IdeaEdgeData>[]
safeIdeas: FeatureIdea[]
onClose: () => void
}
type HandleStats = {
leftHandles: Edge<IdeaEdgeData>[]
rightHandles: Edge<IdeaEdgeData>[]
topHandles: Edge<IdeaEdgeData>[]
bottomHandles: Edge<IdeaEdgeData>[]
leftUnique: number
rightUnique: number
topUnique: number
bottomUnique: number
nodeEdges: Edge<IdeaEdgeData>[]
}
const getHandleStats = (ideaId: string, edges: Edge<IdeaEdgeData>[]): HandleStats => {
const nodeEdges = edges.filter((edge) => edge.source === ideaId || edge.target === ideaId)
const leftHandles = edges.filter((edge) => edge.target === ideaId && edge.targetHandle?.startsWith('left'))
const rightHandles = edges.filter((edge) => edge.source === ideaId && edge.sourceHandle?.startsWith('right'))
const topHandles = edges.filter((edge) => edge.target === ideaId && edge.targetHandle?.startsWith('top'))
const bottomHandles = edges.filter((edge) => edge.source === ideaId && edge.sourceHandle?.startsWith('bottom'))
return {
leftHandles,
rightHandles,
topHandles,
bottomHandles,
leftUnique: new Set(leftHandles.map((edge) => edge.targetHandle)).size,
rightUnique: new Set(rightHandles.map((edge) => edge.sourceHandle)).size,
topUnique: new Set(topHandles.map((edge) => edge.targetHandle)).size,
bottomUnique: new Set(bottomHandles.map((edge) => edge.sourceHandle)).size,
nodeEdges,
}
}
const DebugSummary = ({ nodes, edges, safeIdeas }: Omit<FeatureIdeaDebugPanelProps, 'onClose'>) => (
<div className="grid grid-cols-3 gap-4 p-3 bg-muted/50 rounded-lg text-xs">
<div>
<div className="font-semibold text-foreground mb-1">Total Edges</div>
<div className="text-2xl font-bold text-primary">{edges.length}</div>
</div>
<div>
<div className="font-semibold text-foreground mb-1">Total Nodes</div>
<div className="text-2xl font-bold text-accent">{nodes.length}</div>
</div>
<div>
<div className="font-semibold text-foreground mb-1">Total Ideas</div>
<div className="text-2xl font-bold text-secondary">{safeIdeas.length}</div>
</div>
</div>
)
const ConnectionMatrix = ({ safeIdeas, edges }: Pick<FeatureIdeaDebugPanelProps, 'safeIdeas' | 'edges'>) => (
<div className="space-y-2">
<div className="font-semibold text-xs flex items-center justify-between">
<span>Connection Matrix (Handle Occupancy)</span>
<Badge variant="outline" className="text-xs">
1:1 Constraint Active
</Badge>
</div>
<ScrollArea className="h-48 w-full rounded-md border">
<div className="p-2 space-y-2 text-xs font-mono">
{safeIdeas.slice(0, 10).map((idea) => {
const { nodeEdges, leftHandles, rightHandles, topHandles, bottomHandles, leftUnique, rightUnique, topUnique, bottomUnique } =
getHandleStats(idea.id, edges)
const hasViolation =
leftHandles.length !== leftUnique ||
rightHandles.length !== rightUnique ||
topHandles.length !== topUnique ||
bottomHandles.length !== bottomUnique
return (
<div
key={idea.id}
className={`p-2 rounded border ${hasViolation ? 'bg-red-500/20 border-red-500' : 'bg-muted/30'}`}
>
<div className="font-semibold truncate mb-1 flex items-center gap-2" title={idea.title}>
{hasViolation && <span className="text-red-500"></span>}
{idea.title}
</div>
<div className="grid grid-cols-4 gap-1 text-[10px]">
<div
className={`p-1 rounded text-center ${
leftHandles.length !== leftUnique
? 'bg-red-500/40 text-red-900 dark:text-red-100 font-bold'
: leftHandles.length >= 1
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-muted'
}`}
>
{leftHandles.length}/{leftUnique} {leftHandles.length !== leftUnique ? '⚠️' : leftHandles.length > 0 ? '✓' : '○'}
</div>
<div
className={`p-1 rounded text-center ${
rightHandles.length !== rightUnique
? 'bg-red-500/40 text-red-900 dark:text-red-100 font-bold'
: rightHandles.length >= 1
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-muted'
}`}
>
{rightHandles.length}/{rightUnique} {rightHandles.length !== rightUnique ? '⚠️' : rightHandles.length > 0 ? '✓' : '○'}
</div>
<div
className={`p-1 rounded text-center ${
topHandles.length !== topUnique
? 'bg-red-500/40 text-red-900 dark:text-red-100 font-bold'
: topHandles.length >= 1
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-muted'
}`}
>
{topHandles.length}/{topUnique} {topHandles.length !== topUnique ? '⚠️' : topHandles.length > 0 ? '✓' : '○'}
</div>
<div
className={`p-1 rounded text-center ${
bottomHandles.length !== bottomUnique
? 'bg-red-500/40 text-red-900 dark:text-red-100 font-bold'
: bottomHandles.length >= 1
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-muted'
}`}
>
{bottomHandles.length}/{bottomUnique} {bottomHandles.length !== bottomUnique ? '⚠️' : bottomHandles.length > 0 ? '✓' : '○'}
</div>
</div>
<div className="mt-1 text-[10px] text-muted-foreground">
Total: {nodeEdges.length} connection{nodeEdges.length !== 1 ? 's' : ''}, Handles: L{leftUnique}|R{rightUnique}|T
{topUnique}|B{bottomUnique}
</div>
</div>
)
})}
{safeIdeas.length > 10 && (
<div className="text-center text-muted-foreground py-2">... and {safeIdeas.length - 10} more ideas</div>
)}
</div>
</ScrollArea>
</div>
)
const ConstraintStatus = ({ safeIdeas, edges }: Pick<FeatureIdeaDebugPanelProps, 'safeIdeas' | 'edges'>) => {
const violations: string[] = []
safeIdeas.forEach((idea) => {
const { leftHandles, rightHandles, topHandles, bottomHandles, leftUnique, rightUnique, topUnique, bottomUnique } = getHandleStats(
idea.id,
edges
)
if (leftHandles.length !== leftUnique) {
violations.push(`${idea.title}: Left side has duplicate handle usage (${leftHandles.length} connections on ${leftUnique} handles)`)
}
if (rightHandles.length !== rightUnique) {
violations.push(`${idea.title}: Right side has duplicate handle usage (${rightHandles.length} connections on ${rightUnique} handles)`)
}
if (topHandles.length !== topUnique) {
violations.push(`${idea.title}: Top side has duplicate handle usage (${topHandles.length} connections on ${topUnique} handles)`)
}
if (bottomHandles.length !== bottomUnique) {
violations.push(`${idea.title}: Bottom side has duplicate handle usage (${bottomHandles.length} connections on ${bottomUnique} handles)`)
}
})
if (violations.length > 0) {
return (
<div className="space-y-1 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div className="font-semibold text-xs text-red-700 dark:text-red-300 flex items-center gap-1"> CONSTRAINT VIOLATIONS DETECTED</div>
<div className="text-xs space-y-0.5 text-red-600 dark:text-red-400">
{violations.map((violation, index) => (
<div key={index}> {violation}</div>
))}
</div>
</div>
)
}
return (
<div className="space-y-1 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<div className="font-semibold text-xs text-green-700 dark:text-green-300 flex items-center gap-1"> All Constraints Satisfied</div>
<div className="text-xs space-y-0.5">
<div> Each handle has exactly 1 connection (1:1 mapping) </div>
<div> New connections automatically remove conflicts </div>
<div> Spare blank handles always available </div>
<div> Remapping preserves 1:1 constraint </div>
<div> Changes persist to database immediately </div>
</div>
</div>
)
}
export const FeatureIdeaDebugPanel = ({ nodes, edges, safeIdeas, onClose }: FeatureIdeaDebugPanelProps) => (
<Card className="shadow-2xl border-2">
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm">🔍 Connection Debug Panel</h3>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onClose}>
</Button>
</div>
<DebugSummary nodes={nodes} edges={edges} safeIdeas={safeIdeas} />
<ConnectionMatrix safeIdeas={safeIdeas} edges={edges} />
<ConstraintStatus safeIdeas={safeIdeas} edges={edges} />
</div>
</Card>
)

View File

@@ -0,0 +1,108 @@
import { Edge } from 'reactflow'
import { FeatureIdea, IdeaEdgeData, IdeaGroup } from './types'
import { EdgeDialog } from './dialogs/EdgeDialog'
import { GroupDialog } from './dialogs/GroupDialog'
import { IdeaEditDialog } from './dialogs/IdeaEditDialog'
import { IdeaViewDialog } from './dialogs/IdeaViewDialog'
type FeatureIdeaDialogsProps = {
ideas: FeatureIdea[] | null
groups: IdeaGroup[] | null
safeIdeas: FeatureIdea[]
safeGroups: IdeaGroup[]
edges: Edge<IdeaEdgeData>[]
selectedIdea: FeatureIdea | null
selectedGroup: IdeaGroup | null
selectedEdge: Edge<IdeaEdgeData> | null
editDialogOpen: boolean
groupDialogOpen: boolean
viewDialogOpen: boolean
edgeDialogOpen: boolean
setSelectedIdea: (idea: FeatureIdea | null) => void
setSelectedGroup: (group: IdeaGroup | null) => void
setSelectedEdge: (edge: Edge<IdeaEdgeData> | null) => void
setEditDialogOpen: (open: boolean) => void
setGroupDialogOpen: (open: boolean) => void
setViewDialogOpen: (open: boolean) => void
setEdgeDialogOpen: (open: boolean) => void
handleSaveIdea: () => void
handleDeleteIdea: (id: string) => void
handleSaveGroup: () => void
handleDeleteGroup: (id: string) => void
handleDeleteEdge: (edgeId: string) => void
handleSaveEdge: () => void
}
export const FeatureIdeaDialogs = ({
ideas,
groups,
safeIdeas,
safeGroups,
edges,
selectedIdea,
selectedGroup,
selectedEdge,
editDialogOpen,
groupDialogOpen,
viewDialogOpen,
edgeDialogOpen,
setSelectedIdea,
setSelectedGroup,
setSelectedEdge,
setEditDialogOpen,
setGroupDialogOpen,
setViewDialogOpen,
setEdgeDialogOpen,
handleSaveIdea,
handleDeleteIdea,
handleSaveGroup,
handleDeleteGroup,
handleDeleteEdge,
handleSaveEdge,
}: FeatureIdeaDialogsProps) => (
<>
<GroupDialog
open={groupDialogOpen}
onOpenChange={setGroupDialogOpen}
selectedGroup={selectedGroup}
groups={groups}
setSelectedGroup={setSelectedGroup}
onSave={handleSaveGroup}
onDelete={handleDeleteGroup}
/>
<IdeaEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
selectedIdea={selectedIdea}
ideas={ideas}
safeGroups={safeGroups}
setSelectedIdea={setSelectedIdea}
onSave={handleSaveIdea}
onDelete={handleDeleteIdea}
/>
<IdeaViewDialog
open={viewDialogOpen}
onOpenChange={setViewDialogOpen}
selectedIdea={selectedIdea}
safeGroups={safeGroups}
safeIdeas={safeIdeas}
edges={edges}
onEdit={() => {
setViewDialogOpen(false)
setEditDialogOpen(true)
}}
/>
<EdgeDialog
open={edgeDialogOpen}
onOpenChange={setEdgeDialogOpen}
selectedEdge={selectedEdge}
safeIdeas={safeIdeas}
setSelectedEdge={setSelectedEdge}
onDelete={handleDeleteEdge}
onSave={handleSaveEdge}
/>
</>
)

View File

@@ -0,0 +1,15 @@
import { Panel } from 'reactflow'
export const FeatureIdeaTipsPanel = () => (
<Panel position="bottom-right">
<div className="bg-card border border-border rounded-lg shadow-lg p-2 text-xs text-muted-foreground max-w-sm">
<p className="mb-1">
💡 <strong>Tip:</strong> Double-click ideas to view details
</p>
<p className="mb-1">📦 Create groups to organize related ideas</p>
<p className="mb-1">🔗 Drag from handles on card edges to connect ideas</p>
<p className="mb-1"> Drag existing connection ends to remap them</p>
<p> Click connections to edit or delete them</p>
</div>
</Panel>
)

View File

@@ -0,0 +1,65 @@
import { Panel } from 'reactflow'
import { Plus, Package, Sparkle } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
type FeatureIdeaToolbarProps = {
debugPanelOpen: boolean
setDebugPanelOpen: (open: boolean) => void
handleGenerateIdeas: () => void
handleAddGroup: () => void
handleAddIdea: () => void
}
export const FeatureIdeaToolbar = ({
debugPanelOpen,
setDebugPanelOpen,
handleGenerateIdeas,
handleAddGroup,
handleAddIdea,
}: FeatureIdeaToolbarProps) => (
<Panel position="top-right" className="flex gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setDebugPanelOpen(!debugPanelOpen)}
variant="outline"
className="shadow-lg"
size="icon"
>
🔍
</Button>
</TooltipTrigger>
<TooltipContent>Debug Connection Status</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleGenerateIdeas} variant="outline" className="shadow-lg">
<Sparkle size={20} weight="duotone" />
</Button>
</TooltipTrigger>
<TooltipContent>AI Generate Ideas</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleAddGroup} variant="outline" className="shadow-lg">
<Package size={20} />
</Button>
</TooltipTrigger>
<TooltipContent>Add Group</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleAddIdea} className="shadow-lg">
<Plus size={20} />
</Button>
</TooltipTrigger>
<TooltipContent>Add Idea</TooltipContent>
</Tooltip>
</TooltipProvider>
</Panel>
)

View File

@@ -0,0 +1,90 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Trash } from '@phosphor-icons/react'
import { Edge } from 'reactflow'
import { FeatureIdea, IdeaEdgeData } from '../types'
type EdgeDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
selectedEdge: Edge<IdeaEdgeData> | null
safeIdeas: FeatureIdea[]
setSelectedEdge: (edge: Edge<IdeaEdgeData> | null) => void
onDelete: (edgeId: string) => void
onSave: () => void
}
export const EdgeDialog = ({
open,
onOpenChange,
selectedEdge,
safeIdeas,
setSelectedEdge,
onDelete,
onSave,
}: EdgeDialogProps) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Connection Details</DialogTitle>
<DialogDescription>Manage the relationship between ideas</DialogDescription>
</DialogHeader>
{selectedEdge && selectedEdge.data && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-muted-foreground">From</label>
<p className="text-sm font-medium">{safeIdeas.find((idea) => idea.id === selectedEdge.source)?.title}</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">To</label>
<p className="text-sm font-medium">{safeIdeas.find((idea) => idea.id === selectedEdge.target)?.title}</p>
</div>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Label</label>
<Input
value={selectedEdge.data?.label || ''}
onChange={(e) =>
setSelectedEdge({
...selectedEdge,
data: {
...selectedEdge.data!,
label: e.target.value,
},
})
}
placeholder="relates to"
/>
</div>
<div className="p-3 bg-muted rounded-lg text-sm">
<p className="font-medium mb-2">💡 Connection Info:</p>
<p className="text-xs text-muted-foreground">
Each connection point can have exactly one arrow. Creating a new connection from an occupied point will
automatically remap the existing connection.
</p>
</div>
</div>
)}
<DialogFooter>
<div className="flex justify-between w-full">
<Button variant="destructive" onClick={() => selectedEdge && onDelete(selectedEdge.id)}>
<Trash size={16} className="mr-2" />
Delete
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onSave}>Save</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)

View File

@@ -0,0 +1,93 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Trash } from '@phosphor-icons/react'
import { GROUP_COLORS } from '../constants'
import { IdeaGroup } from '../types'
type GroupDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
selectedGroup: IdeaGroup | null
groups: IdeaGroup[] | null
setSelectedGroup: (group: IdeaGroup | null) => void
onSave: () => void
onDelete: (id: string) => void
}
export const GroupDialog = ({
open,
onOpenChange,
selectedGroup,
groups,
setSelectedGroup,
onSave,
onDelete,
}: GroupDialogProps) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedGroup?.label ? 'Edit Group' : 'New Group'}</DialogTitle>
<DialogDescription>Create a container to organize related ideas</DialogDescription>
</DialogHeader>
{selectedGroup && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">Group Name</label>
<Input
value={selectedGroup.label}
onChange={(e) => setSelectedGroup({ ...selectedGroup, label: e.target.value })}
placeholder="e.g., Authentication Features"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Color</label>
<div className="grid grid-cols-4 gap-2">
{GROUP_COLORS.map((color) => (
<button
key={color.value}
onClick={() => setSelectedGroup({ ...selectedGroup, color: color.value })}
className={`h-12 rounded-lg border-2 transition-all hover:scale-105 ${
selectedGroup.color === color.value ? 'border-foreground ring-2 ring-primary' : 'border-border'
}`}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
<div className="p-3 bg-muted rounded-lg text-sm">
<p className="font-medium mb-1">💡 Tips:</p>
<ul className="space-y-1 text-xs text-muted-foreground">
<li> Groups provide visual organization for related ideas</li>
<li> Drag ideas into groups or assign them in the idea editor</li>
<li> Ideas stay within their group boundaries when moved</li>
</ul>
</div>
</div>
)}
<DialogFooter>
<div className="flex justify-between w-full">
<div>
{selectedGroup && (groups || []).find((group) => group.id === selectedGroup.id) && (
<Button variant="destructive" onClick={() => onDelete(selectedGroup.id)}>
<Trash size={16} className="mr-2" />
Delete
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onSave}>Save Group</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)

View File

@@ -0,0 +1,143 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Trash } from '@phosphor-icons/react'
import { CATEGORIES, PRIORITIES, STATUSES } from '../constants'
import { FeatureIdea, IdeaGroup } from '../types'
type IdeaEditDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
selectedIdea: FeatureIdea | null
ideas: FeatureIdea[] | null
safeGroups: IdeaGroup[]
setSelectedIdea: (idea: FeatureIdea | null) => void
onSave: () => void
onDelete: (id: string) => void
}
export const IdeaEditDialog = ({
open,
onOpenChange,
selectedIdea,
ideas,
safeGroups,
setSelectedIdea,
onSave,
onDelete,
}: IdeaEditDialogProps) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{selectedIdea?.title ? 'Edit Idea' : 'New Idea'}</DialogTitle>
<DialogDescription>Create or modify a feature idea for your app</DialogDescription>
</DialogHeader>
{selectedIdea && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">Title</label>
<Input
value={selectedIdea.title}
onChange={(e) => setSelectedIdea({ ...selectedIdea, title: e.target.value })}
placeholder="Feature name..."
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Description</label>
<Textarea
value={selectedIdea.description}
onChange={(e) => setSelectedIdea({ ...selectedIdea, description: e.target.value })}
placeholder="Describe the feature..."
rows={4}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Category</label>
<select
value={selectedIdea.category}
onChange={(e) => setSelectedIdea({ ...selectedIdea, category: e.target.value })}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
{CATEGORIES.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Priority</label>
<select
value={selectedIdea.priority}
onChange={(e) => setSelectedIdea({ ...selectedIdea, priority: e.target.value as FeatureIdea['priority'] })}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
{PRIORITIES.map((priority) => (
<option key={priority} value={priority}>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Status</label>
<select
value={selectedIdea.status}
onChange={(e) => setSelectedIdea({ ...selectedIdea, status: e.target.value as FeatureIdea['status'] })}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
{STATUSES.map((status) => (
<option key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' ')}
</option>
))}
</select>
</div>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Group</label>
<select
value={selectedIdea.parentGroup || ''}
onChange={(e) => setSelectedIdea({ ...selectedIdea, parentGroup: e.target.value || undefined })}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">No group</option>
{safeGroups.map((group) => (
<option key={group.id} value={group.id}>
{group.label}
</option>
))}
</select>
</div>
</div>
)}
<DialogFooter>
<div className="flex justify-between w-full">
<div>
{selectedIdea && (ideas || []).find((idea) => idea.id === selectedIdea.id) && (
<Button variant="destructive" onClick={() => onDelete(selectedIdea.id)}>
<Trash size={16} className="mr-2" />
Delete
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onSave}>Save Idea</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)

View File

@@ -0,0 +1,105 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { STATUS_COLORS } from '../constants'
import { FeatureIdea, IdeaGroup, IdeaEdgeData } from '../types'
import { Edge } from 'reactflow'
type IdeaViewDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
selectedIdea: FeatureIdea | null
safeGroups: IdeaGroup[]
safeIdeas: FeatureIdea[]
edges: Edge<IdeaEdgeData>[]
onEdit: () => void
}
export const IdeaViewDialog = ({
open,
onOpenChange,
selectedIdea,
safeGroups,
safeIdeas,
edges,
onEdit,
}: IdeaViewDialogProps) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedIdea?.title}</DialogTitle>
</DialogHeader>
{selectedIdea && (
<div className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">{selectedIdea.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-muted-foreground">Category</label>
<p className="text-sm font-medium">{selectedIdea.category}</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Priority</label>
<p className="text-sm font-medium capitalize">{selectedIdea.priority}</p>
</div>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Status</label>
<Badge className={`${STATUS_COLORS[selectedIdea.status]} mt-1`}>{selectedIdea.status}</Badge>
</div>
{selectedIdea.parentGroup && (
<div>
<label className="text-xs font-medium text-muted-foreground">Group</label>
<p className="text-sm font-medium">
{safeGroups.find((group) => group.id === selectedIdea.parentGroup)?.label || 'Unknown'}
</p>
</div>
)}
<div>
<label className="text-xs font-medium text-muted-foreground">Created</label>
<p className="text-sm">{new Date(selectedIdea.createdAt).toLocaleDateString()}</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-2 block">Connections</label>
<div className="space-y-2">
{edges
.filter((edge) => edge.source === selectedIdea.id || edge.target === selectedIdea.id)
.map((edge) => {
const otherIdeaId = edge.source === selectedIdea.id ? edge.target : edge.source
const otherIdea = safeIdeas.find((idea) => idea.id === otherIdeaId)
const isOutgoing = edge.source === selectedIdea.id
return (
<div key={edge.id} className="flex items-center gap-2 text-sm p-2 bg-muted rounded">
<span className="flex-1">{isOutgoing ? '→' : '←'} {otherIdea?.title || 'Unknown'}</span>
{edge.data?.label && (
<Badge variant="outline" className="text-xs">
{edge.data.label}
</Badge>
)}
</div>
)
})}
{edges.filter((edge) => edge.source === selectedIdea.id || edge.target === selectedIdea.id).length === 0 && (
<p className="text-sm text-muted-foreground">No connections</p>
)}
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={onEdit}>Edit</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)

View File

@@ -0,0 +1,45 @@
import { Edge, MarkerType } from 'reactflow'
import seedData from '@/data/feature-idea-cloud.json'
import { FeatureIdea, IdeaEdgeData } from './types'
import { CONNECTION_STYLE } from './constants'
type SeedIdea = Omit<FeatureIdea, 'createdAt'> & { createdAtOffset: number }
type SeedEdge = {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
label: string
}
export const buildSeedIdeas = (): FeatureIdea[] => {
const now = Date.now()
return (seedData.ideas as SeedIdea[]).map((idea) => ({
...idea,
createdAt: now + idea.createdAtOffset,
}))
}
export const buildSeedEdges = (): Edge<IdeaEdgeData>[] =>
(seedData.edges as SeedEdge[]).map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: 'default',
animated: false,
data: { label: edge.label },
markerEnd: {
type: MarkerType.ArrowClosed,
color: CONNECTION_STYLE.stroke,
width: 20,
height: 20,
},
style: {
stroke: CONNECTION_STYLE.stroke,
strokeWidth: CONNECTION_STYLE.strokeWidth,
},
}))

View File

@@ -0,0 +1,722 @@
import { MouseEvent as ReactMouseEvent, useState, useEffect, useCallback, useRef } from 'react'
import { Edge, Node, useNodesState, useEdgesState, Connection as RFConnection, MarkerType, reconnectEdge } from 'reactflow'
import { toast } from 'sonner'
import { useKV } from '@/hooks/use-kv'
import { FeatureIdea, IdeaGroup, IdeaEdgeData } from './types'
import { CATEGORIES, CONNECTION_STYLE, GROUP_COLORS } from './constants'
import { buildSeedEdges, buildSeedIdeas } from './seed-data'
type SeedRef = {
ideas: FeatureIdea[]
edges: Edge<IdeaEdgeData>[]
}
export type FeatureIdeaCloudState = {
nodes: Node[]
edges: Edge<IdeaEdgeData>[]
ideas: FeatureIdea[] | null
groups: IdeaGroup[] | null
safeIdeas: FeatureIdea[]
safeGroups: IdeaGroup[]
selectedIdea: FeatureIdea | null
selectedGroup: IdeaGroup | null
selectedEdge: Edge<IdeaEdgeData> | null
editDialogOpen: boolean
groupDialogOpen: boolean
viewDialogOpen: boolean
edgeDialogOpen: boolean
debugPanelOpen: boolean
setSelectedIdea: (idea: FeatureIdea | null) => void
setSelectedGroup: (group: IdeaGroup | null) => void
setSelectedEdge: (edge: Edge<IdeaEdgeData> | null) => void
setEditDialogOpen: (open: boolean) => void
setGroupDialogOpen: (open: boolean) => void
setViewDialogOpen: (open: boolean) => void
setEdgeDialogOpen: (open: boolean) => void
setDebugPanelOpen: (open: boolean) => void
onNodesChangeWrapper: (changes: any) => void
onEdgesChangeWrapper: (changes: any) => void
onConnect: (params: RFConnection) => void
onEdgeClick: (event: ReactMouseEvent, edge: Edge<IdeaEdgeData>) => void
onNodeDoubleClick: (event: ReactMouseEvent, node: Node<FeatureIdea>) => void
onReconnectStart: () => void
onReconnect: (oldEdge: Edge, newConnection: RFConnection) => void
onReconnectEnd: (_: MouseEvent | TouchEvent, edge: Edge) => void
handleAddIdea: () => void
handleAddGroup: () => void
handleSaveIdea: () => void
handleDeleteIdea: (id: string) => void
handleSaveGroup: () => void
handleDeleteGroup: (id: string) => void
handleDeleteEdge: (edgeId: string) => void
handleSaveEdge: () => void
handleGenerateIdeas: () => Promise<void>
}
export const useFeatureIdeaCloud = (): FeatureIdeaCloudState => {
const seedRef = useRef<SeedRef | null>(null)
if (!seedRef.current) {
seedRef.current = { ideas: buildSeedIdeas(), edges: buildSeedEdges() }
}
const seedIdeas = seedRef.current.ideas
const seedEdges = seedRef.current.edges
const [ideas, setIdeas] = useKV<FeatureIdea[]>('feature-ideas', seedIdeas)
const [groups, setGroups] = useKV<IdeaGroup[]>('feature-idea-groups', [])
const [savedEdges, setSavedEdges] = useKV<Edge<IdeaEdgeData>[]>('feature-idea-edges', seedEdges)
const [savedNodePositions, setSavedNodePositions] = useKV<Record<string, { x: number; y: number }>>('feature-idea-node-positions', {})
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [selectedIdea, setSelectedIdea] = useState<FeatureIdea | null>(null)
const [selectedGroup, setSelectedGroup] = useState<IdeaGroup | null>(null)
const [selectedEdge, setSelectedEdge] = useState<Edge<IdeaEdgeData> | null>(null)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [groupDialogOpen, setGroupDialogOpen] = useState(false)
const [viewDialogOpen, setViewDialogOpen] = useState(false)
const [edgeDialogOpen, setEdgeDialogOpen] = useState(false)
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
const edgeReconnectSuccessful = useRef(true)
const safeIdeas = ideas || seedIdeas
const safeGroups = groups || []
const safeEdges = savedEdges || []
const safeNodePositions = savedNodePositions || {}
const updateNodeConnectionCounts = useCallback((edgeList: Edge<IdeaEdgeData>[]) => {
const nodeConnectionMap = new Map<string, Record<string, Set<string>>>()
edgeList.forEach((edge) => {
const sourceHandle = edge.sourceHandle || 'default'
const targetHandle = edge.targetHandle || 'default'
if (!nodeConnectionMap.has(edge.source)) {
nodeConnectionMap.set(edge.source, { left: new Set(), right: new Set(), top: new Set(), bottom: new Set() })
}
if (!nodeConnectionMap.has(edge.target)) {
nodeConnectionMap.set(edge.target, { left: new Set(), right: new Set(), top: new Set(), bottom: new Set() })
}
const sourceSide = sourceHandle.split('-')[0]
const targetSide = targetHandle.split('-')[0]
nodeConnectionMap.get(edge.source)![sourceSide].add(sourceHandle)
nodeConnectionMap.get(edge.target)![targetSide].add(targetHandle)
})
nodeConnectionMap.forEach((connections, nodeId) => {
const counts = {
left: connections.left.size,
right: connections.right.size,
top: connections.top.size,
bottom: connections.bottom.size,
}
const event = new CustomEvent('updateConnectionCounts', {
detail: { nodeId, counts },
})
window.dispatchEvent(event)
})
}, [])
useEffect(() => {
if (!ideas || ideas.length === 0) {
setIdeas(seedIdeas)
}
}, [ideas, seedIdeas, setIdeas])
useEffect(() => {
const groupNodes: Node<IdeaGroup>[] = safeGroups.map((group) => ({
id: group.id,
type: 'groupNode',
position: safeNodePositions[group.id] || { x: 0, y: 0 },
data: group,
style: {
zIndex: -1,
},
}))
const ideaNodes: Node<FeatureIdea>[] = safeIdeas.map((idea, index) => ({
id: idea.id,
type: 'ideaNode',
position: safeNodePositions[idea.id] || { x: 100 + (index % 3) * 350, y: 100 + Math.floor(index / 3) * 250 },
data: idea,
parentNode: idea.parentGroup,
extent: idea.parentGroup ? 'parent' : undefined,
style: {
zIndex: 1,
},
}))
setNodes([...groupNodes, ...ideaNodes])
}, [safeIdeas, safeGroups, safeNodePositions, setNodes])
useEffect(() => {
setEdges(safeEdges)
updateNodeConnectionCounts(safeEdges)
}, [safeEdges, setEdges, updateNodeConnectionCounts])
useEffect(() => {
const handleEditIdea = (e: Event) => {
const customEvent = e as CustomEvent<FeatureIdea>
setSelectedIdea(customEvent.detail)
setEditDialogOpen(true)
}
const handleEditGroup = (e: Event) => {
const customEvent = e as CustomEvent<IdeaGroup>
setSelectedGroup(customEvent.detail)
setGroupDialogOpen(true)
}
window.addEventListener('editIdea', handleEditIdea)
window.addEventListener('editGroup', handleEditGroup)
return () => {
window.removeEventListener('editIdea', handleEditIdea)
window.removeEventListener('editGroup', handleEditGroup)
}
}, [])
const onNodesChangeWrapper = useCallback(
(changes: any) => {
onNodesChange(changes)
const moveChange = changes.find((c: any) => c.type === 'position' && c.dragging === false)
if (moveChange) {
setTimeout(() => {
setNodes((currentNodes) => {
const positions: Record<string, { x: number; y: number }> = {}
currentNodes.forEach((node) => {
if (node.position) {
positions[node.id] = node.position
}
})
setSavedNodePositions(positions)
return currentNodes
})
setEdges((currentEdges) => {
setSavedEdges(currentEdges)
return currentEdges
})
}, 100)
}
},
[onNodesChange, setNodes, setEdges, setSavedNodePositions, setSavedEdges]
)
const onEdgesChangeWrapper = useCallback(
(changes: any) => {
onEdgesChange(changes)
setTimeout(() => {
setEdges((currentEdges) => {
setSavedEdges(currentEdges)
updateNodeConnectionCounts(currentEdges)
return currentEdges
})
}, 100)
},
[onEdgesChange, setEdges, setSavedEdges, updateNodeConnectionCounts]
)
const validateAndRemoveConflicts = useCallback(
(
edgeList: Edge<IdeaEdgeData>[],
sourceNodeId: string,
sourceHandleId: string,
targetNodeId: string,
targetHandleId: string,
excludeEdgeId?: string
): { filteredEdges: Edge<IdeaEdgeData>[]; removedCount: number; conflicts: string[] } => {
const edgesToRemove: string[] = []
const conflicts: string[] = []
console.log('[Validator] Checking for conflicts:', {
newConnection: `${sourceNodeId}[${sourceHandleId}] -> ${targetNodeId}[${targetHandleId}]`,
existingEdges: edgeList.length,
excludeEdgeId,
})
edgeList.forEach((edge) => {
if (excludeEdgeId && edge.id === excludeEdgeId) {
console.log('[Validator] Skipping excluded edge:', edge.id)
return
}
const edgeSourceHandle = edge.sourceHandle || 'default'
const edgeTargetHandle = edge.targetHandle || 'default'
const hasSourceConflict = edge.source === sourceNodeId && edgeSourceHandle === sourceHandleId
const hasTargetConflict = edge.target === targetNodeId && edgeTargetHandle === targetHandleId
if (hasSourceConflict && !edgesToRemove.includes(edge.id)) {
edgesToRemove.push(edge.id)
conflicts.push(`Source: ${edge.source}[${edgeSourceHandle}] was connected to ${edge.target}[${edgeTargetHandle}]`)
console.log('[Validator] SOURCE CONFLICT DETECTED:', edge.id, edge)
}
if (hasTargetConflict && !edgesToRemove.includes(edge.id)) {
edgesToRemove.push(edge.id)
conflicts.push(`Target: ${edge.target}[${edgeTargetHandle}] was connected from ${edge.source}[${edgeSourceHandle}]`)
console.log('[Validator] TARGET CONFLICT DETECTED:', edge.id, edge)
}
})
const filteredEdges = edgeList.filter((edge) => !edgesToRemove.includes(edge.id))
console.log('[Validator] Conflicts found:', conflicts.length, 'edges to remove:', edgesToRemove)
return {
filteredEdges,
removedCount: edgesToRemove.length,
conflicts,
}
},
[]
)
const onConnect = useCallback(
(params: RFConnection) => {
if (!params.source || !params.target) return
const sourceNodeId = params.source
const sourceHandleId = params.sourceHandle || 'default'
const targetNodeId = params.target
const targetHandleId = params.targetHandle || 'default'
console.log('[Connection] ==== NEW CONNECTION ATTEMPT ====')
console.log('[Connection] Source:', `${sourceNodeId}[${sourceHandleId}]`)
console.log('[Connection] Target:', `${targetNodeId}[${targetHandleId}]`)
setEdges((eds) => {
console.log('[Connection] Current edges BEFORE validation:', eds.length)
eds.forEach((edge) => {
console.log(` - ${edge.id}: ${edge.source}[${edge.sourceHandle || 'default'}] -> ${edge.target}[${edge.targetHandle || 'default'}]`)
})
const { filteredEdges, removedCount, conflicts } = validateAndRemoveConflicts(
eds,
sourceNodeId,
sourceHandleId,
targetNodeId,
targetHandleId
)
console.log('[Connection] Edges AFTER conflict removal:', filteredEdges.length)
const newEdge: Edge<IdeaEdgeData> = {
id: `edge-${Date.now()}`,
source: sourceNodeId,
target: targetNodeId,
sourceHandle: sourceHandleId,
targetHandle: targetHandleId,
type: 'default',
data: { label: 'relates to' },
markerEnd: {
type: MarkerType.ArrowClosed,
color: CONNECTION_STYLE.stroke,
width: 20,
height: 20,
},
style: {
stroke: CONNECTION_STYLE.stroke,
strokeWidth: CONNECTION_STYLE.strokeWidth,
},
animated: false,
}
console.log('[Connection] Creating new edge:', newEdge.id)
const updatedEdges = [...filteredEdges, newEdge]
console.log('[Connection] Total edges AFTER addition:', updatedEdges.length)
console.log('[Connection] Final edge list:')
updatedEdges.forEach((edge) => {
console.log(` - ${edge.id}: ${edge.source}[${edge.sourceHandle || 'default'}] -> ${edge.target}[${edge.targetHandle || 'default'}]`)
})
setSavedEdges(updatedEdges)
updateNodeConnectionCounts(updatedEdges)
if (removedCount > 0) {
setTimeout(() => {
toast.success(`Connection remapped! (${removedCount} old connection${removedCount > 1 ? 's' : ''} removed)`, {
description: conflicts.join('\n'),
})
}, 0)
} else {
setTimeout(() => {
toast.success('Ideas connected!')
}, 0)
}
return updatedEdges
})
},
[setEdges, setSavedEdges, validateAndRemoveConflicts, updateNodeConnectionCounts]
)
const onEdgeClick = useCallback((event: ReactMouseEvent, edge: Edge<IdeaEdgeData>) => {
setSelectedEdge(edge)
setEdgeDialogOpen(true)
}, [])
const onNodeDoubleClick = useCallback((event: ReactMouseEvent, node: Node<FeatureIdea>) => {
setSelectedIdea(node.data)
setViewDialogOpen(true)
}, [])
const onReconnectStart = useCallback(() => {
edgeReconnectSuccessful.current = false
}, [])
const onReconnect = useCallback(
(oldEdge: Edge, newConnection: RFConnection) => {
if (!newConnection.source || !newConnection.target) return
const sourceNodeId = newConnection.source
const sourceHandleId = newConnection.sourceHandle || 'default'
const targetNodeId = newConnection.target
const targetHandleId = newConnection.targetHandle || 'default'
console.log('[Reconnection] Remapping edge:', {
oldEdgeId: oldEdge.id,
oldSource: `${oldEdge.source}[${oldEdge.sourceHandle || 'default'}]`,
oldTarget: `${oldEdge.target}[${oldEdge.targetHandle || 'default'}]`,
newSource: `${sourceNodeId}[${sourceHandleId}]`,
newTarget: `${targetNodeId}[${targetHandleId}]`,
})
edgeReconnectSuccessful.current = true
setEdges((els) => {
const { filteredEdges, removedCount, conflicts } = validateAndRemoveConflicts(
els,
sourceNodeId,
sourceHandleId,
targetNodeId,
targetHandleId,
oldEdge.id
)
const updatedEdges = reconnectEdge(oldEdge, newConnection, filteredEdges)
console.log('[Reconnection] Edge remapped successfully')
console.log('[Reconnection] Total edges after remapping:', updatedEdges.length)
console.log('[Reconnection] Edges by handle:', updatedEdges.map((edge) => ({
id: edge.id,
source: `${edge.source}[${edge.sourceHandle || 'default'}]`,
target: `${edge.target}[${edge.targetHandle || 'default'}]`,
})))
setSavedEdges(updatedEdges)
updateNodeConnectionCounts(updatedEdges)
if (removedCount > 0) {
setTimeout(() => {
toast.success(
`Connection remapped! (${removedCount} conflicting connection${removedCount > 1 ? 's' : ''} removed)`,
{
description: conflicts.join('\n'),
}
)
}, 0)
} else {
setTimeout(() => {
toast.success('Connection remapped!')
}, 0)
}
return updatedEdges
})
},
[setEdges, setSavedEdges, validateAndRemoveConflicts, updateNodeConnectionCounts]
)
const onReconnectEnd = useCallback(
(_: MouseEvent | TouchEvent, edge: Edge) => {
if (!edgeReconnectSuccessful.current) {
setEdges((eds) => {
const updatedEdges = eds.filter((current) => current.id !== edge.id)
setSavedEdges(updatedEdges)
return updatedEdges
})
}
edgeReconnectSuccessful.current = true
},
[setEdges, setSavedEdges]
)
const handleAddIdea = () => {
const newIdea: FeatureIdea = {
id: `idea-${Date.now()}`,
title: '',
description: '',
category: 'Other',
priority: 'medium',
status: 'idea',
createdAt: Date.now(),
}
setSelectedIdea(newIdea)
setEditDialogOpen(true)
}
const handleAddGroup = () => {
const newGroup: IdeaGroup = {
id: `group-${Date.now()}`,
label: '',
color: GROUP_COLORS[0].value,
createdAt: Date.now(),
}
setSelectedGroup(newGroup)
setGroupDialogOpen(true)
}
const handleSaveIdea = () => {
if (!selectedIdea || !selectedIdea.title.trim()) {
toast.error('Please enter a title')
return
}
setIdeas((currentIdeas) => {
const existing = (currentIdeas || []).find((idea) => idea.id === selectedIdea.id)
if (existing) {
return (currentIdeas || []).map((idea) => (idea.id === selectedIdea.id ? selectedIdea : idea))
}
return [...(currentIdeas || []), selectedIdea]
})
if (!(ideas || []).find((idea) => idea.id === selectedIdea.id)) {
const newPosition = { x: 400, y: 300 }
const newNode: Node<FeatureIdea> = {
id: selectedIdea.id,
type: 'ideaNode',
position: newPosition,
data: selectedIdea,
}
setNodes((nds) => [...nds, newNode])
setSavedNodePositions((currentPositions) => ({
...(currentPositions || {}),
[selectedIdea.id]: newPosition,
}))
}
setEditDialogOpen(false)
setSelectedIdea(null)
toast.success('Idea saved!')
}
const handleDeleteIdea = (id: string) => {
setIdeas((currentIdeas) => (currentIdeas || []).filter((idea) => idea.id !== id))
setNodes((nds) => nds.filter((node) => node.id !== id))
setSavedNodePositions((currentPositions) => {
const newPositions = { ...(currentPositions || {}) }
delete newPositions[id]
return newPositions
})
const updatedEdges = edges.filter((edge) => edge.source !== id && edge.target !== id)
setEdges(updatedEdges)
setSavedEdges(updatedEdges)
updateNodeConnectionCounts(updatedEdges)
setEditDialogOpen(false)
setViewDialogOpen(false)
setSelectedIdea(null)
toast.success('Idea deleted')
}
const handleSaveGroup = () => {
if (!selectedGroup || !selectedGroup.label.trim()) {
toast.error('Please enter a group name')
return
}
setGroups((currentGroups) => {
const existing = (currentGroups || []).find((group) => group.id === selectedGroup.id)
if (existing) {
return (currentGroups || []).map((group) => (group.id === selectedGroup.id ? selectedGroup : group))
}
return [...(currentGroups || []), selectedGroup]
})
if (!(groups || []).find((group) => group.id === selectedGroup.id)) {
const newPosition = { x: 200, y: 200 }
const newNode: Node<IdeaGroup> = {
id: selectedGroup.id,
type: 'groupNode',
position: newPosition,
data: selectedGroup,
style: {
zIndex: -1,
},
}
setNodes((nds) => [newNode, ...nds])
setSavedNodePositions((currentPositions) => ({
...(currentPositions || {}),
[selectedGroup.id]: newPosition,
}))
}
setGroupDialogOpen(false)
setSelectedGroup(null)
toast.success('Group saved!')
}
const handleDeleteGroup = (id: string) => {
setIdeas((currentIdeas) =>
(currentIdeas || []).map((idea) => (idea.parentGroup === id ? { ...idea, parentGroup: undefined } : idea))
)
setGroups((currentGroups) => (currentGroups || []).filter((group) => group.id !== id))
setNodes((nds) => nds.filter((node) => node.id !== id))
setSavedNodePositions((currentPositions) => {
const newPositions = { ...(currentPositions || {}) }
delete newPositions[id]
return newPositions
})
setGroupDialogOpen(false)
setSelectedGroup(null)
toast.success('Group deleted')
}
const handleDeleteEdge = (edgeId: string) => {
const updatedEdges = edges.filter((edge) => edge.id !== edgeId)
setEdges(updatedEdges)
setSavedEdges(updatedEdges)
updateNodeConnectionCounts(updatedEdges)
setEdgeDialogOpen(false)
setSelectedEdge(null)
toast.success('Connection removed')
}
const handleSaveEdge = () => {
if (selectedEdge) {
const updatedEdge = {
...selectedEdge,
data: selectedEdge.data,
markerEnd: {
type: MarkerType.ArrowClosed,
color: CONNECTION_STYLE.stroke,
width: 20,
height: 20,
},
style: {
stroke: CONNECTION_STYLE.stroke,
strokeWidth: CONNECTION_STYLE.strokeWidth,
},
animated: false,
}
const updatedEdges = edges.map((edge) => (edge.id === selectedEdge.id ? updatedEdge : edge))
setEdges(updatedEdges)
setSavedEdges(updatedEdges)
setEdgeDialogOpen(false)
toast.success('Connection updated!')
}
}
const handleGenerateIdeas = async () => {
toast.info('Generating ideas with AI...')
try {
const categoryList = CATEGORIES.join('|')
const promptText = `Generate 3 innovative feature ideas for a low-code application builder. Each idea should be practical and valuable. Return as JSON with this structure:
{
"ideas": [
{
"title": "Feature Name",
"description": "Brief description",
"category": "${categoryList}",
"priority": "low|medium|high"
}
]
}`
const response = await window.spark.llm(promptText, 'gpt-4o-mini', true)
const result = JSON.parse(response)
if (result.ideas && Array.isArray(result.ideas)) {
const newIdeas: FeatureIdea[] = result.ideas.map((idea: any) => ({
id: `idea-ai-${Date.now()}-${Math.random()}`,
title: idea.title,
description: idea.description,
category: idea.category || 'Other',
priority: idea.priority || 'medium',
status: 'idea' as const,
createdAt: Date.now(),
}))
setIdeas((currentIdeas) => [...(currentIdeas || []), ...newIdeas])
const newPositions: Record<string, { x: number; y: number }> = {}
const newNodes: Node<FeatureIdea>[] = newIdeas.map((idea, index) => {
const position = { x: 400 + index * 250, y: 300 + index * 150 }
newPositions[idea.id] = position
return {
id: idea.id,
type: 'ideaNode',
position,
data: idea,
}
})
setNodes((nds) => [...nds, ...newNodes])
setSavedNodePositions((currentPositions) => ({
...(currentPositions || {}),
...newPositions,
}))
toast.success(`Generated ${newIdeas.length} new ideas!`)
}
} catch (error) {
console.error('Failed to generate ideas:', error)
toast.error('Failed to generate ideas')
}
}
return {
nodes,
edges,
ideas,
groups,
safeIdeas,
safeGroups,
selectedIdea,
selectedGroup,
selectedEdge,
editDialogOpen,
groupDialogOpen,
viewDialogOpen,
edgeDialogOpen,
debugPanelOpen,
setSelectedIdea,
setSelectedGroup,
setSelectedEdge,
setEditDialogOpen,
setGroupDialogOpen,
setViewDialogOpen,
setEdgeDialogOpen,
setDebugPanelOpen,
onNodesChangeWrapper,
onEdgesChangeWrapper,
onConnect,
onEdgeClick,
onNodeDoubleClick,
onReconnectStart,
onReconnect,
onReconnectEnd,
handleAddIdea,
handleAddGroup,
handleSaveIdea,
handleDeleteIdea,
handleSaveGroup,
handleDeleteGroup,
handleDeleteEdge,
handleSaveEdge,
handleGenerateIdeas,
}
}

View File

@@ -0,0 +1,120 @@
{
"ideas": [
{
"id": "idea-1",
"title": "AI Code Assistant",
"description": "Integrate an AI assistant that can suggest code improvements and answer questions",
"category": "AI/ML",
"priority": "high",
"status": "completed",
"createdAtOffset": -10000000
},
{
"id": "idea-2",
"title": "Real-time Collaboration",
"description": "Allow multiple developers to work on the same project simultaneously",
"category": "Collaboration",
"priority": "high",
"status": "idea",
"createdAtOffset": -9000000
},
{
"id": "idea-3",
"title": "Component Marketplace",
"description": "A marketplace where users can share and download pre-built components",
"category": "Community",
"priority": "medium",
"status": "idea",
"createdAtOffset": -8000000
},
{
"id": "idea-4",
"title": "Visual Git Integration",
"description": "Git operations through a visual interface with branch visualization",
"category": "DevOps",
"priority": "high",
"status": "planned",
"createdAtOffset": -7000000
},
{
"id": "idea-5",
"title": "API Mock Server",
"description": "Built-in mock server for testing API integrations",
"category": "Testing",
"priority": "medium",
"status": "idea",
"createdAtOffset": -6000000
},
{
"id": "idea-6",
"title": "Performance Profiler",
"description": "Analyze and optimize application performance with visual metrics",
"category": "Performance",
"priority": "medium",
"status": "idea",
"createdAtOffset": -5000000
},
{
"id": "idea-7",
"title": "Theme Presets",
"description": "Pre-designed theme templates for quick project setup",
"category": "Design",
"priority": "low",
"status": "completed",
"createdAtOffset": -4000000
},
{
"id": "idea-8",
"title": "Database Schema Migrations",
"description": "Visual tool for creating and managing database migrations",
"category": "Database",
"priority": "high",
"status": "in-progress",
"createdAtOffset": -3000000
},
{
"id": "idea-9",
"title": "Mobile App Preview",
"description": "Live preview on actual mobile devices or simulators",
"category": "Mobile",
"priority": "medium",
"status": "planned",
"createdAtOffset": -2000000
},
{
"id": "idea-10",
"title": "Accessibility Checker",
"description": "Automated accessibility testing and suggestions",
"category": "Accessibility",
"priority": "high",
"status": "idea",
"createdAtOffset": -1000000
}
],
"edges": [
{
"id": "edge-1",
"source": "idea-1",
"target": "idea-8",
"sourceHandle": "right-0",
"targetHandle": "left-0",
"label": "requires"
},
{
"id": "edge-2",
"source": "idea-2",
"target": "idea-4",
"sourceHandle": "bottom-0",
"targetHandle": "top-0",
"label": "works with"
},
{
"id": "edge-3",
"source": "idea-8",
"target": "idea-5",
"sourceHandle": "bottom-0",
"targetHandle": "left-0",
"label": "includes"
}
]
}

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react'
import { useKV } from '@/hooks/use-kv'
import seedData from '@/data/feature-idea-cloud.json'
export interface FeatureIdea {
id: string
@@ -12,35 +13,17 @@ export interface FeatureIdea {
parentGroup?: string
}
const SEED_IDEAS: FeatureIdea[] = [
{
id: 'idea-1',
title: 'AI Code Assistant',
description: 'Integrate an AI assistant that can suggest code improvements and answer questions',
category: 'AI/ML',
priority: 'high',
status: 'completed',
createdAt: Date.now() - 10000000,
},
{
id: 'idea-2',
title: 'Real-time Collaboration',
description: 'Allow multiple developers to work on the same project simultaneously',
category: 'Collaboration',
priority: 'high',
status: 'idea',
createdAt: Date.now() - 9000000,
},
{
id: 'idea-3',
title: 'Component Marketplace',
description: 'A marketplace where users can share and download pre-built components',
category: 'Community',
priority: 'medium',
status: 'idea',
createdAt: Date.now() - 8000000,
},
]
type SeedIdea = Omit<FeatureIdea, 'createdAt'> & { createdAtOffset: number }
const buildSeedIdeas = (): FeatureIdea[] => {
const now = Date.now()
return (seedData.ideas as SeedIdea[]).map((idea) => ({
...idea,
createdAt: now + idea.createdAtOffset,
}))
}
const SEED_IDEAS = buildSeedIdeas()
export function useFeatureIdeas() {
const [ideas, setIdeas] = useKV<FeatureIdea[]>('feature-ideas', SEED_IDEAS)