From 2a42c42edd7dde4b919f4d4726306963bcec9ae4 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Fri, 16 Jan 2026 16:02:24 +0000 Subject: [PATCH] Generated by Spark: Would be nice if I could connect ideas together like UML --- src/components/FeatureIdeaCloud.tsx | 458 +++++++++++++++++++++++++--- 1 file changed, 415 insertions(+), 43 deletions(-) diff --git a/src/components/FeatureIdeaCloud.tsx b/src/components/FeatureIdeaCloud.tsx index 6af1897..399fb15 100644 --- a/src/components/FeatureIdeaCloud.tsx +++ b/src/components/FeatureIdeaCloud.tsx @@ -7,10 +7,20 @@ import { Badge } from '@/components/ui/badge' import { Textarea } from '@/components/ui/textarea' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { Plus, Trash, Sparkle, MagnifyingGlassMinus, MagnifyingGlassPlus, ArrowsOut, Hand, Link as LinkIcon, Selection, DotsThree } from '@phosphor-icons/react' +import { Plus, Trash, Sparkle, MagnifyingGlassMinus, MagnifyingGlassPlus, ArrowsOut, Hand, Link as LinkIcon, Selection, DotsThree, X } from '@phosphor-icons/react' import { motion } from 'framer-motion' import { toast } from 'sonner' +type ConnectionType = 'dependency' | 'association' | 'inheritance' | 'composition' | 'aggregation' + +interface Connection { + id: string + fromId: string + toId: string + type: ConnectionType + label?: string +} + interface FeatureIdea { id: string title: string @@ -22,6 +32,7 @@ interface FeatureIdea { x: number y: number connectedTo?: string[] + connections?: Connection[] } const SEED_IDEAS: FeatureIdea[] = [ @@ -35,7 +46,6 @@ const SEED_IDEAS: FeatureIdea[] = [ createdAt: Date.now() - 10000000, x: 100, y: 150, - connectedTo: ['idea-8'], }, { id: 'idea-2', @@ -47,7 +57,6 @@ const SEED_IDEAS: FeatureIdea[] = [ createdAt: Date.now() - 9000000, x: 600, y: 250, - connectedTo: ['idea-4'], }, { id: 'idea-3', @@ -70,7 +79,6 @@ const SEED_IDEAS: FeatureIdea[] = [ createdAt: Date.now() - 7000000, x: 700, y: 600, - connectedTo: ['idea-8'], }, { id: 'idea-5', @@ -115,7 +123,6 @@ const SEED_IDEAS: FeatureIdea[] = [ createdAt: Date.now() - 3000000, x: 300, y: 400, - connectedTo: ['idea-5'], }, { id: 'idea-9', @@ -141,9 +148,32 @@ const SEED_IDEAS: FeatureIdea[] = [ }, ] +const SEED_CONNECTIONS: Connection[] = [ + { id: 'conn-1', fromId: 'idea-1', toId: 'idea-8', type: 'dependency', label: 'requires' }, + { id: 'conn-2', fromId: 'idea-2', toId: 'idea-4', type: 'association', label: 'works with' }, + { id: 'conn-3', fromId: 'idea-8', toId: 'idea-5', type: 'composition', label: 'includes' }, +] + const CATEGORIES = ['AI/ML', 'Collaboration', 'Community', 'DevOps', 'Testing', 'Performance', 'Design', 'Database', 'Mobile', 'Accessibility', 'Productivity', 'Security', 'Analytics', 'Other'] const PRIORITIES = ['low', 'medium', 'high'] as const const STATUSES = ['idea', 'planned', 'in-progress', 'completed'] as const +const CONNECTION_TYPES = ['dependency', 'association', 'inheritance', 'composition', 'aggregation'] as const + +const CONNECTION_STYLES = { + dependency: { stroke: 'hsl(var(--accent))', strokeDasharray: '8,4', arrowType: 'open' }, + association: { stroke: 'hsl(var(--primary))', strokeDasharray: '', arrowType: 'line' }, + inheritance: { stroke: 'hsl(var(--chart-2))', strokeDasharray: '', arrowType: 'hollow' }, + composition: { stroke: 'hsl(var(--destructive))', strokeDasharray: '', arrowType: 'diamond-filled' }, + aggregation: { stroke: 'hsl(var(--chart-4))', strokeDasharray: '', arrowType: 'diamond-hollow' }, +} + +const CONNECTION_LABELS = { + dependency: 'depends on', + association: 'relates to', + inheritance: 'extends', + composition: 'contains', + aggregation: 'has', +} const STATUS_COLORS = { idea: 'bg-muted text-muted-foreground', @@ -160,9 +190,12 @@ const PRIORITY_COLORS = { export function FeatureIdeaCloud() { const [ideas, setIdeas] = useKV('feature-ideas', SEED_IDEAS) + const [connections, setConnections] = useKV('feature-connections', SEED_CONNECTIONS) const [selectedIdea, setSelectedIdea] = useState(null) + const [selectedConnection, setSelectedConnection] = useState(null) const [editDialogOpen, setEditDialogOpen] = useState(false) const [viewDialogOpen, setViewDialogOpen] = useState(false) + const [connectionDialogOpen, setConnectionDialogOpen] = useState(false) const canvasRef = useRef(null) const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) @@ -172,15 +205,20 @@ export function FeatureIdeaCloud() { const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) const [tool, setTool] = useState<'select' | 'pan' | 'connect'>('select') const [connectingFrom, setConnectingFrom] = useState(null) - const [filtersPanelOpen, setFiltersPanelOpen] = useState(false) + const [connectionType, setConnectionType] = useState('association') + const [hoveredConnection, setHoveredConnection] = useState(null) const safeIdeas = ideas || SEED_IDEAS + const safeConnections = connections || SEED_CONNECTIONS useEffect(() => { if (!ideas || ideas.length === 0) { setIdeas(SEED_IDEAS) } - }, [ideas, setIdeas]) + if (!connections || connections.length === 0) { + setConnections(SEED_CONNECTIONS) + } + }, [ideas, setIdeas, connections, setConnections]) const handleAddIdea = () => { const canvasCenterX = (window.innerWidth / 2 - pan.x) / zoom @@ -211,21 +249,26 @@ export function FeatureIdeaCloud() { e.stopPropagation() if (!connectingFrom) { setConnectingFrom(idea.id) - toast.info('Click another idea to connect') + toast.info(`Click another idea to connect (${CONNECTION_LABELS[connectionType]})`) } else if (connectingFrom !== idea.id) { - setIdeas((currentIdeas) => - (currentIdeas || []).map(i => { - if (i.id === connectingFrom) { - const connectedTo = i.connectedTo || [] - if (!connectedTo.includes(idea.id)) { - return { ...i, connectedTo: [...connectedTo, idea.id] } - } - } - return i - }) + const existingConnection = safeConnections.find( + c => c.fromId === connectingFrom && c.toId === idea.id ) + + if (existingConnection) { + toast.error('Connection already exists') + } else { + const newConnection: Connection = { + id: `conn-${Date.now()}`, + fromId: connectingFrom, + toId: idea.id, + type: connectionType, + label: CONNECTION_LABELS[connectionType], + } + setConnections((current) => [...(current || []), newConnection]) + toast.success('Ideas connected!') + } setConnectingFrom(null) - toast.success('Ideas connected!') } return } @@ -261,12 +304,30 @@ export function FeatureIdeaCloud() { const handleDeleteIdea = (id: string) => { setIdeas((currentIdeas) => (currentIdeas || []).filter(i => i.id !== id)) + setConnections((currentConnections) => + (currentConnections || []).filter(c => c.fromId !== id && c.toId !== id) + ) setEditDialogOpen(false) setViewDialogOpen(false) setSelectedIdea(null) toast.success('Idea deleted') } + const handleDeleteConnection = (connectionId: string) => { + setConnections((current) => (current || []).filter(c => c.id !== connectionId)) + setConnectionDialogOpen(false) + setSelectedConnection(null) + toast.success('Connection removed') + } + + const handleConnectionClick = (connection: Connection, e: React.MouseEvent) => { + if (tool === 'select') { + e.stopPropagation() + setSelectedConnection(connection) + setConnectionDialogOpen(true) + } + } + const handleGenerateIdeas = async () => { toast.info('Generating ideas with AI...') @@ -386,34 +447,175 @@ export function FeatureIdeaCloud() { setPan({ x: newPanX, y: newPanY }) } + const renderArrowhead = (connection: Connection, x: number, y: number, angle: number) => { + const style = CONNECTION_STYLES[connection.type] + const size = 12 + + if (style.arrowType === 'open') { + const points = [ + [x, y], + [x - size * Math.cos(angle - Math.PI / 6), y - size * Math.sin(angle - Math.PI / 6)], + [x - size * Math.cos(angle + Math.PI / 6), y - size * Math.sin(angle + Math.PI / 6)] + ] + return ( + p.join(',')).join(' ')} + fill="none" + stroke={style.stroke} + strokeWidth={2} + /> + ) + } else if (style.arrowType === 'line') { + return ( + + ) + } else if (style.arrowType === 'hollow') { + const points = [ + [x, y], + [x - size * Math.cos(angle - Math.PI / 6), y - size * Math.sin(angle - Math.PI / 6)], + [x - size * 0.7 * Math.cos(angle), y - size * 0.7 * Math.sin(angle)], + [x - size * Math.cos(angle + Math.PI / 6), y - size * Math.sin(angle + Math.PI / 6)] + ] + return ( + p.join(',')).join(' ')} + fill="hsl(var(--background))" + stroke={style.stroke} + strokeWidth={2} + /> + ) + } else if (style.arrowType === 'diamond-filled') { + const points = [ + [x, y], + [x - size * 0.6 * Math.cos(angle - Math.PI / 3), y - size * 0.6 * Math.sin(angle - Math.PI / 3)], + [x - size * Math.cos(angle), y - size * Math.sin(angle)], + [x - size * 0.6 * Math.cos(angle + Math.PI / 3), y - size * 0.6 * Math.sin(angle + Math.PI / 3)] + ] + return ( + p.join(',')).join(' ')} + fill={style.stroke} + stroke={style.stroke} + strokeWidth={2} + /> + ) + } else if (style.arrowType === 'diamond-hollow') { + const points = [ + [x, y], + [x - size * 0.6 * Math.cos(angle - Math.PI / 3), y - size * 0.6 * Math.sin(angle - Math.PI / 3)], + [x - size * Math.cos(angle), y - size * Math.sin(angle)], + [x - size * 0.6 * Math.cos(angle + Math.PI / 3), y - size * 0.6 * Math.sin(angle + Math.PI / 3)] + ] + return ( + p.join(',')).join(' ')} + fill="hsl(var(--background))" + stroke={style.stroke} + strokeWidth={2} + /> + ) + } + return null + } + const renderConnections = () => { - const connections: React.ReactNode[] = [] - safeIdeas.forEach((fromIdea) => { - fromIdea.connectedTo?.forEach((toId) => { - const toIdea = safeIdeas.find(i => i.id === toId) - if (toIdea) { - const fromX = fromIdea.x * zoom + pan.x + 120 - const fromY = fromIdea.y * zoom + pan.y + 80 - const toX = toIdea.x * zoom + pan.x + 120 - const toY = toIdea.y * zoom + pan.y + 80 - - connections.push( + const elements: React.ReactNode[] = [] + + safeConnections.forEach((connection) => { + const fromIdea = safeIdeas.find(i => i.id === connection.fromId) + const toIdea = safeIdeas.find(i => i.id === connection.toId) + + if (fromIdea && toIdea) { + const fromX = fromIdea.x * zoom + pan.x + 120 + const fromY = fromIdea.y * zoom + pan.y + 80 + const toX = toIdea.x * zoom + pan.x + 120 + const toY = toIdea.y * zoom + pan.y + 80 + + const dx = toX - fromX + const dy = toY - fromY + const angle = Math.atan2(dy, dx) + const distance = Math.sqrt(dx * dx + dy * dy) + + const arrowSize = 12 + const endX = toX - arrowSize * Math.cos(angle) + const endY = toY - arrowSize * Math.sin(angle) + + const midX = (fromX + toX) / 2 + const midY = (fromY + toY) / 2 + + const style = CONNECTION_STYLES[connection.type] + const isHovered = hoveredConnection === connection.id + + elements.push( + setHoveredConnection(connection.id)} + onMouseLeave={() => setHoveredConnection(null)} + onClick={(e) => handleConnectionClick(connection, e as any)} /> - ) - } - }) + + setHoveredConnection(connection.id)} + onMouseLeave={() => setHoveredConnection(null)} + onClick={(e) => handleConnectionClick(connection, e as any)} + /> + + {renderArrowhead(connection, toX, toY, angle)} + + {(isHovered || connection.label) && ( + + + + {connection.label || CONNECTION_LABELS[connection.type]} + + + )} + + ) + } }) - return connections + + return elements } return ( @@ -472,6 +674,23 @@ export function FeatureIdeaCloud() { + {tool === 'connect' && ( +
+ +
+ )} +
@@ -530,8 +749,34 @@ export function FeatureIdeaCloud() {
-
-

💡 Tip: Double-click to view, drag to move, scroll to zoom

+
+

Connection Types:

+
+ {CONNECTION_TYPES.map(type => { + const style = CONNECTION_STYLES[type] + return ( +
+ + + + {type} +
+ ) + })} +
+
+ +
+

💡 Tip: Double-click ideas to view details

+

🔗 Use Connect tool to create UML-style relationships

Created

{new Date(selectedIdea.createdAt).toLocaleDateString()}

+ +
+ +
+ {safeConnections + .filter(c => c.fromId === selectedIdea.id || c.toId === selectedIdea.id) + .map(conn => { + const otherIdea = safeIdeas.find(i => + i.id === (conn.fromId === selectedIdea.id ? conn.toId : conn.fromId) + ) + const isOutgoing = conn.fromId === selectedIdea.id + return ( +
+ {conn.type} + + {isOutgoing ? '→' : '←'} {otherIdea?.title || 'Unknown'} + +
+ ) + })} + {safeConnections.filter(c => c.fromId === selectedIdea.id || c.toId === selectedIdea.id).length === 0 && ( +

No connections

+ )} +
+
)} @@ -776,6 +1046,108 @@ export function FeatureIdeaCloud() { + + + + + Connection Details + + Manage the relationship between ideas + + + + {selectedConnection && ( +
+
+
+ +

+ {safeIdeas.find(i => i.id === selectedConnection.fromId)?.title} +

+
+
+ +

+ {safeIdeas.find(i => i.id === selectedConnection.toId)?.title} +

+
+
+ +
+ + +
+ +
+ + setSelectedConnection({ + ...selectedConnection, + label: e.target.value + })} + placeholder={CONNECTION_LABELS[selectedConnection.type]} + /> +
+ +
+

Connection Type Guide:

+
    +
  • Dependency: One feature needs another to function
  • +
  • Association: Features work together or are related
  • +
  • Inheritance: One feature extends another
  • +
  • Composition: One feature contains another as essential part
  • +
  • Aggregation: One feature has another as optional part
  • +
+
+
+ )} + + +
+ +
+ + +
+
+
+
+
) }