-
Notifications
You must be signed in to change notification settings - Fork 2
Claude/graph sharing feature x9qdt #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
88e23f9
ecc0791
fc9b1cd
53d6418
6aaf28b
1c67a21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import uuid | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ('block_manager', '0004_add_user_to_project'), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name='project', | ||
| name='share_token', | ||
| field=models.UUIDField( | ||
| blank=True, | ||
| db_index=True, | ||
| default=None, | ||
| help_text='Unique token for public sharing; generated on first share', | ||
| null=True, | ||
| unique=True, | ||
| ), | ||
| ), | ||
| migrations.AddField( | ||
| model_name='project', | ||
| name='is_shared', | ||
| field=models.BooleanField( | ||
| default=False, | ||
| help_text='Whether this project is publicly accessible via share link', | ||
| ), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -209,9 +209,10 @@ class Meta: | |
| model = Project | ||
| fields = [ | ||
| 'id', 'name', 'description', 'framework', | ||
| 'share_token', 'is_shared', | ||
| 'created_at', 'updated_at' | ||
| ] | ||
| read_only_fields = ['id', 'created_at', 'updated_at'] | ||
| read_only_fields = ['id', 'share_token', 'is_shared', 'created_at', 'updated_at'] | ||
|
Comment on lines
211
to
+215
|
||
|
|
||
|
|
||
| class ProjectDetailSerializer(serializers.ModelSerializer): | ||
|
|
@@ -222,9 +223,10 @@ class Meta: | |
| model = Project | ||
| fields = [ | ||
| 'id', 'name', 'description', 'framework', | ||
| 'share_token', 'is_shared', | ||
| 'architecture', 'created_at', 'updated_at' | ||
| ] | ||
| read_only_fields = ['id', 'created_at', 'updated_at'] | ||
| read_only_fields = ['id', 'share_token', 'is_shared', 'created_at', 'updated_at'] | ||
|
|
||
|
|
||
| class SaveArchitectureSerializer(serializers.Serializer): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import uuid | ||
| from rest_framework import status | ||
| from rest_framework.decorators import api_view | ||
| from rest_framework.response import Response | ||
| from django.shortcuts import get_object_or_404 | ||
|
|
||
| from block_manager.models import Project | ||
|
|
||
|
|
||
| @api_view(['GET']) | ||
| def get_shared_project(request, share_token): | ||
| """ | ||
| Public endpoint — returns project metadata for a shared project. | ||
| No authentication required. | ||
| Returns 404 if the token doesn't exist or sharing is disabled. | ||
| """ | ||
| try: | ||
| project = Project.objects.get(share_token=share_token, is_shared=True) | ||
| except Project.DoesNotExist: | ||
| return Response( | ||
| {'error': 'Shared project not found or link is no longer active'}, | ||
| status=status.HTTP_404_NOT_FOUND | ||
| ) | ||
|
|
||
| owner_display_name = None | ||
| if project.user: | ||
| owner_display_name = project.user.display_name or "Anonymous" | ||
|
|
||
| return Response({ | ||
| 'name': project.name, | ||
| 'description': project.description, | ||
| 'framework': project.framework, | ||
| 'owner_display_name': owner_display_name, | ||
| 'share_token': str(project.share_token), | ||
| }) | ||
|
|
||
|
|
||
| @api_view(['GET']) | ||
| def get_shared_architecture(request, share_token): | ||
| """ | ||
| Public endpoint — returns the canvas state for a shared project. | ||
| No authentication required. | ||
| Returns 404 if the token doesn't exist or sharing is disabled. | ||
| """ | ||
| try: | ||
| project = Project.objects.get(share_token=share_token, is_shared=True) | ||
| except Project.DoesNotExist: | ||
| return Response( | ||
| {'error': 'Shared project not found or link is no longer active'}, | ||
| status=status.HTTP_404_NOT_FOUND | ||
| ) | ||
|
|
||
| try: | ||
| architecture = project.architecture | ||
| except Exception: | ||
| return Response({'nodes': [], 'edges': [], 'groupDefinitions': []}) | ||
|
|
||
| if architecture.canvas_state: | ||
| return Response(architecture.canvas_state) | ||
|
|
||
| return Response({'nodes': [], 'edges': [], 'groupDefinitions': []}) | ||
|
|
||
|
|
||
| @api_view(['POST']) | ||
| def enable_sharing(request, project_id): | ||
| """ | ||
| Enable public sharing for a project. | ||
| Authentication required; only the project owner can call this. | ||
| Generates a share_token on first use; reuses the existing token on subsequent calls | ||
| so the perma-link stays stable. | ||
| """ | ||
| if not hasattr(request, 'firebase_user') or not request.firebase_user: | ||
| return Response( | ||
| {'error': 'Authentication required'}, | ||
| status=status.HTTP_401_UNAUTHORIZED | ||
| ) | ||
|
|
||
| project = get_object_or_404(Project, pk=project_id) | ||
|
|
||
| if project.user != request.firebase_user: | ||
| return Response( | ||
| {'error': 'You do not have permission to share this project'}, | ||
| status=status.HTTP_403_FORBIDDEN | ||
| ) | ||
|
|
||
| if project.share_token is None: | ||
| project.share_token = uuid.uuid4() | ||
|
|
||
| project.is_shared = True | ||
| project.save(update_fields=['share_token', 'is_shared']) | ||
|
|
||
| return Response({ | ||
| 'share_token': str(project.share_token), | ||
| 'is_shared': project.is_shared, | ||
| }) | ||
|
|
||
|
|
||
| @api_view(['DELETE']) | ||
| def disable_sharing(request, project_id): | ||
| """ | ||
| Disable public sharing for a project. | ||
| Authentication required; only the project owner can call this. | ||
| The share_token is preserved so re-enabling restores the same URL. | ||
| """ | ||
| if not hasattr(request, 'firebase_user') or not request.firebase_user: | ||
| return Response( | ||
| {'error': 'Authentication required'}, | ||
| status=status.HTTP_401_UNAUTHORIZED | ||
| ) | ||
|
|
||
| project = get_object_or_404(Project, pk=project_id) | ||
|
|
||
| if project.user != request.firebase_user: | ||
| return Response( | ||
| {'error': 'You do not have permission to modify this project'}, | ||
| status=status.HTTP_403_FORBIDDEN | ||
| ) | ||
|
|
||
| project.is_shared = False | ||
| project.save(update_fields=['is_shared']) | ||
|
|
||
| return Response({'is_shared': False}) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,9 +37,10 @@ const nodeTypes = { | |
| interface CanvasProps { | ||
| onDragStart: (type: string) => void | ||
| onRegisterAddNode: (handler: (blockType: string) => void) => void | ||
| readOnly?: boolean | ||
| } | ||
|
|
||
| function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (blockType: string) => void) => void }) { | ||
| function FlowCanvas({ onRegisterAddNode, readOnly = false }: { onRegisterAddNode: (handler: (blockType: string) => void) => void; readOnly?: boolean }) { | ||
| const { | ||
| nodes, | ||
| edges, | ||
|
|
@@ -88,8 +89,9 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block | |
| // Validation is now triggered manually via the Validate button in Header | ||
| // Removed automatic validation on nodes/edges change | ||
|
|
||
| // Keyboard shortcuts for undo/redo/delete/group/expand | ||
| // Keyboard shortcuts for undo/redo/delete/group/expand (disabled in read-only mode) | ||
| useEffect(() => { | ||
| if (readOnly) return | ||
| const handleKeyDown = (e: KeyboardEvent) => { | ||
| // Check for Ctrl (Windows/Linux) or Cmd (Mac) | ||
| const isMod = e.ctrlKey || e.metaKey | ||
|
|
@@ -130,7 +132,7 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block | |
|
|
||
| window.addEventListener('keydown', handleKeyDown) | ||
| return () => window.removeEventListener('keydown', handleKeyDown) | ||
| }, [undo, redo, removeNode, removeEdge, selectedNodeId, selectedEdgeId, setSelectedEdgeId, nodes]) | ||
| }, [readOnly, undo, redo, removeNode, removeEdge, selectedNodeId, selectedEdgeId, setSelectedEdgeId, nodes]) | ||
|
|
||
| // Find a suitable position for a new node | ||
| const findAvailablePosition = useCallback(() => { | ||
|
|
@@ -400,6 +402,22 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block | |
| [nodes, setNodes] | ||
| ) | ||
|
|
||
| // In read-only mode we still need to propagate selection changes so that | ||
| // ReactFlow sets node.selected=true (which reveals the group-block expand button | ||
| // and drives ConfigPanel display). We filter out position/remove/add changes so | ||
| // nothing can mutate the graph structure. | ||
| const onNodesChangeReadOnly = useCallback( | ||
| (changes: any) => { | ||
| const allowed = changes.filter((c: any) => | ||
| c.type === 'select' || c.type === 'dimensions' || c.type === 'reset' | ||
| ) | ||
| if (allowed.length > 0) { | ||
| setNodes(applyNodeChanges(allowed, nodes)) | ||
| } | ||
| }, | ||
| [nodes, setNodes] | ||
| ) | ||
|
|
||
| const onEdgesChange = useCallback( | ||
| (changes: any) => { | ||
| setEdges(applyEdgeChanges(changes, edges)) | ||
|
|
@@ -688,32 +706,33 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block | |
| return ( | ||
| <div | ||
| className="flex-1 h-full" | ||
| onDrop={onDrop} | ||
| onDragOver={onDragOver} | ||
| onDrop={readOnly ? undefined : onDrop} | ||
| onDragOver={readOnly ? undefined : onDragOver} | ||
| > | ||
| <HistoryToolbar /> | ||
| {!readOnly && <HistoryToolbar />} | ||
| <ReactFlow | ||
| nodes={nodesWithHandlers} | ||
| edges={edges} | ||
| onNodesChange={onNodesChange} | ||
| onEdgesChange={onEdgesChange} | ||
| onConnect={onConnect} | ||
| onNodeClick={isInteractive ? onNodeClick : undefined} | ||
| onEdgeClick={isInteractive ? onEdgeClick : undefined} | ||
| onPaneClick={isInteractive ? onPaneClick : undefined} | ||
| onPaneContextMenu={isInteractive ? onPaneContextMenu : undefined} | ||
| onNodeContextMenu={isInteractive ? onNodeContextMenu : undefined} | ||
| onReconnect={onReconnect} | ||
| edgesReconnectable={true} | ||
| onNodesChange={readOnly ? onNodesChangeReadOnly : onNodesChange} | ||
| onEdgesChange={readOnly ? undefined : onEdgesChange} | ||
|
Comment on lines
713
to
+717
|
||
| onConnect={readOnly ? undefined : onConnect} | ||
| onNodeClick={onNodeClick} | ||
| onEdgeClick={readOnly ? undefined : onEdgeClick} | ||
| onPaneClick={onPaneClick} | ||
| onPaneContextMenu={readOnly ? undefined : (isInteractive ? onPaneContextMenu : undefined)} | ||
| onNodeContextMenu={readOnly ? undefined : (isInteractive ? onNodeContextMenu : undefined)} | ||
| onReconnect={readOnly ? undefined : onReconnect} | ||
| edgesReconnectable={!readOnly} | ||
| nodeTypes={nodeTypes} | ||
| connectionLineComponent={CustomConnectionLine} | ||
| fitView | ||
| minZoom={0.5} | ||
| maxZoom={1.5} | ||
| nodesDraggable={isInteractive} | ||
| nodesConnectable={isInteractive} | ||
| elementsSelectable={isInteractive} | ||
| onInteractiveChange={setIsInteractive} | ||
| nodesDraggable={readOnly ? false : isInteractive} | ||
| nodesConnectable={readOnly ? false : isInteractive} | ||
| elementsSelectable={true} | ||
| deleteKeyCode={readOnly ? null : undefined} | ||
| onInteractiveChange={readOnly ? undefined : setIsInteractive} | ||
| defaultEdgeOptions={{ | ||
| animated: true, | ||
| style: { stroke: '#6366f1', strokeWidth: 2 } | ||
|
|
@@ -751,7 +770,7 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block | |
| position="bottom-right" | ||
| /> | ||
| </ReactFlow> | ||
| {contextMenu && ( | ||
| {!readOnly && contextMenu && ( | ||
| <ContextMenu | ||
| x={contextMenu.x} | ||
| y={contextMenu.y} | ||
|
|
@@ -803,10 +822,10 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block | |
| ) | ||
| } | ||
|
|
||
| export default function Canvas({ onDragStart, onRegisterAddNode }: CanvasProps) { | ||
| export default function Canvas({ onDragStart, onRegisterAddNode, readOnly = false }: CanvasProps) { | ||
| return ( | ||
| <ReactFlowProvider> | ||
| <FlowCanvas onRegisterAddNode={onRegisterAddNode} /> | ||
| <FlowCanvas onRegisterAddNode={onRegisterAddNode} readOnly={readOnly} /> | ||
| </ReactFlowProvider> | ||
| ) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
share_token is marked unique=True, which already creates an index in most databases; db_index=True is likely redundant and may create unnecessary DB overhead. Consider removing db_index=True here (and in the migration) unless there’s a DB-specific reason to keep it.