diff --git a/project/block_manager/migrations/0005_project_sharing.py b/project/block_manager/migrations/0005_project_sharing.py new file mode 100644 index 0000000..b3cae10 --- /dev/null +++ b/project/block_manager/migrations/0005_project_sharing.py @@ -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', + ), + ), + ] diff --git a/project/block_manager/models.py b/project/block_manager/models.py index 57fa317..35c578d 100644 --- a/project/block_manager/models.py +++ b/project/block_manager/models.py @@ -28,6 +28,18 @@ class Project(models.Model): choices=[('pytorch', 'PyTorch'), ('tensorflow', 'TensorFlow')], default='pytorch' ) + share_token = models.UUIDField( + default=None, + null=True, + blank=True, + unique=True, + db_index=True, + help_text='Unique token for public sharing; generated on first share' + ) + is_shared = models.BooleanField( + default=False, + help_text='Whether this project is publicly accessible via share link' + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -36,7 +48,7 @@ class Meta: def __str__(self): return self.name - + class ModelArchitecture(models.Model): """Stores the architecture graph for a project""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/project/block_manager/serializers.py b/project/block_manager/serializers.py index a76b2c5..4894db9 100644 --- a/project/block_manager/serializers.py +++ b/project/block_manager/serializers.py @@ -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'] 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): diff --git a/project/block_manager/urls.py b/project/block_manager/urls.py index 1207bc6..67d95ab 100644 --- a/project/block_manager/urls.py +++ b/project/block_manager/urls.py @@ -13,6 +13,12 @@ from block_manager.views.export_views import export_model from block_manager.views.chat_views import chat_message, get_suggestions, get_environment_info from block_manager.views.group_views import group_definition_list, group_definition_detail +from block_manager.views.sharing_views import ( + get_shared_project, + get_shared_architecture, + enable_sharing, + disable_sharing, +) # Create router for viewsets router = DefaultRouter() @@ -47,4 +53,10 @@ # Environment info endpoint path('environment', get_environment_info, name='environment-info'), + + # Sharing endpoints + path('shared//', get_shared_project, name='shared-project'), + path('shared//architecture/', get_shared_architecture, name='shared-architecture'), + path('projects//share/', enable_sharing, name='enable-sharing'), + path('projects//unshare/', disable_sharing, name='disable-sharing'), ] diff --git a/project/block_manager/views/sharing_views.py b/project/block_manager/views/sharing_views.py new file mode 100644 index 0000000..fbe90a6 --- /dev/null +++ b/project/block_manager/views/sharing_views.py @@ -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}) diff --git a/project/frontend/src/App.tsx b/project/frontend/src/App.tsx index 9a2f6b3..97d1c72 100644 --- a/project/frontend/src/App.tsx +++ b/project/frontend/src/App.tsx @@ -15,6 +15,7 @@ const ConfigPanel = lazy(() => import('./components/ConfigPanel')) const ChatBot = lazy(() => import('./components/ChatBot')) const LandingPage = lazy(() => import('./landing').then(module => ({ default: module.LandingPage }))) const Dashboard = lazy(() => import('./pages/Dashboard').then(module => ({ default: module.Dashboard }))) +const SharedProjectCanvas = lazy(() => import('./components/SharedProjectCanvas')) // Loading spinner component function LoadingSpinner() { @@ -163,6 +164,7 @@ function App() { /> } /> } /> + } /> ) diff --git a/project/frontend/src/components/Canvas.tsx b/project/frontend/src/components/Canvas.tsx index ea1efa7..70d466c 100644 --- a/project/frontend/src/components/Canvas.tsx +++ b/project/frontend/src/components/Canvas.tsx @@ -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 (
- + {!readOnly && } - {contextMenu && ( + {!readOnly && contextMenu && ( - + ) } diff --git a/project/frontend/src/components/ConfigPanel.tsx b/project/frontend/src/components/ConfigPanel.tsx index e0f2116..d0232fb 100644 --- a/project/frontend/src/components/ConfigPanel.tsx +++ b/project/frontend/src/components/ConfigPanel.tsx @@ -14,7 +14,11 @@ import { toast } from 'sonner' import CustomLayerModal from './CustomLayerModal' import InternalNodeConfigPanel from './InternalNodeConfigPanel' -export default function ConfigPanel() { +interface ConfigPanelProps { + readOnly?: boolean +} + +export default function ConfigPanel({ readOnly = false }: ConfigPanelProps) { const { nodes, selectedNodeId, updateNode, setSelectedNodeId, removeNode, repeatGroupBlock, groupDefinitions } = useModelBuilderStore() const [isCustomModalOpen, setIsCustomModalOpen] = useState(false) const [repeatCount, setRepeatCount] = useState(2) @@ -50,24 +54,15 @@ export default function ConfigPanel() { } } - // Automatically open modal when custom block is selected + // Automatically open modal when custom block is selected (not in read-only mode) useEffect(() => { - if (selectedNode?.data.blockType === 'custom') { + if (!readOnly && selectedNode?.data.blockType === 'custom') { setIsCustomModalOpen(true) } - }, [selectedNode?.id, selectedNode?.data.blockType]) + }, [readOnly, selectedNode?.id, selectedNode?.data.blockType]) // Handle expanded internal node configuration if (isExpandedInternal && parentGroupNodeId && groupDefinitionId && internalNodeId) { - // Debug logging - console.log('ConfigPanel - Routing to InternalNodeConfigPanel:', { - selectedNodeId: selectedNode.id, - parentGroupNodeId, - groupDefinitionId, - internalNodeId, - isExpandedInternal - }) - return ( setSelectedNodeId(null)} + readOnly={readOnly} /> ) } - // For custom blocks, don't show the sidebar at all - only the modal + // For custom blocks: show modal in edit mode, show read-only sidebar in read-only mode if (selectedNode?.data.blockType === 'custom') { - return ( - { - setIsCustomModalOpen(false) - setSelectedNodeId(null) // Deselect the node when modal closes - }} - onSave={handleCustomLayerSave} - initialConfig={{ - name: selectedNode.data.config.name as string | undefined, - code: selectedNode.data.config.code as string | undefined, - output_shape: selectedNode.data.config.output_shape as string | undefined, - description: selectedNode.data.config.description as string | undefined - }} - /> - ) + if (readOnly) { + // Fall through to normal panel rendering below (shows config values as text) + } else { + return ( + { + setIsCustomModalOpen(false) + setSelectedNodeId(null) + }} + onSave={handleCustomLayerSave} + initialConfig={{ + name: selectedNode.data.config.name as string | undefined, + code: selectedNode.data.config.code as string | undefined, + output_shape: selectedNode.data.config.output_shape as string | undefined, + description: selectedNode.data.config.description as string | undefined + }} + /> + ) + } } // Define handleDelete early so it can be used in all sections @@ -165,47 +165,49 @@ export default function ConfigPanel() { )} -
-
Repeat Block
-
-
- - setRepeatCount(parseInt(e.target.value) || 1)} - placeholder="Enter count" - /> -

Create 1-10 copies

-
-
- - setRepeatSpacingX(parseInt(e.target.value) || 0)} - placeholder="Enter spacing" - /> -
-
- - setRepeatSpacingY(parseInt(e.target.value) || 0)} - placeholder="Enter spacing" - /> + {!readOnly && ( +
+
Repeat Block
+
+
+ + setRepeatCount(parseInt(e.target.value) || 1)} + placeholder="Enter count" + /> +

Create 1-10 copies

+
+
+ + setRepeatSpacingX(parseInt(e.target.value) || 0)} + placeholder="Enter spacing" + /> +
+
+ + setRepeatSpacingY(parseInt(e.target.value) || 0)} + placeholder="Enter spacing" + /> +
+
-
-
+ )} )} @@ -229,16 +231,18 @@ export default function ConfigPanel() {
-
- -
+ {!readOnly && ( +
+ +
+ )}
) } @@ -334,7 +338,8 @@ export default function ConfigPanel() { handleConfigChange(field.name, e.target.value)} + onChange={(e) => !readOnly && handleConfigChange(field.name, e.target.value)} + readOnly={readOnly} placeholder={field.placeholder || `Enter ${field.label.toLowerCase()}`} className={`font-mono text-sm ${ field.name === 'shape' && selectedNode.data.config[field.name] && !isValidTensorShape(String(selectedNode.data.config[field.name])) @@ -345,7 +350,7 @@ export default function ConfigPanel() { {field.name === 'shape' && selectedNode.data.config[field.name] && !isValidTensorShape(String(selectedNode.data.config[field.name])) && (

Invalid shape format. Use JSON array like [1, 3, 224, 224]

)} - {field.name === 'shape' && ( + {!readOnly && field.name === 'shape' && (

Quick Presets:

@@ -389,7 +394,8 @@ export default function ConfigPanel() { min={field.min} max={field.max} value={Number(selectedNode.data.config[field.name] ?? field.default ?? 0)} - onChange={(e) => handleConfigChange(field.name, parseFloat(e.target.value) || 0)} + onChange={(e) => !readOnly && handleConfigChange(field.name, parseFloat(e.target.value) || 0)} + readOnly={readOnly} placeholder={`Enter ${field.label.toLowerCase()}`} /> )} @@ -398,7 +404,8 @@ export default function ConfigPanel() {
handleConfigChange(field.name, checked)} + onCheckedChange={(checked) => !readOnly && handleConfigChange(field.name, checked)} + disabled={readOnly} /> {selectedNode.data.config[field.name] ? 'Enabled' : 'Disabled'} @@ -409,7 +416,8 @@ export default function ConfigPanel() { {field.type === 'select' && field.options && ( (fileInputRefs.current[field.name] = el)} @@ -530,16 +538,18 @@ export default function ConfigPanel() {
-
- -
+ {!readOnly && ( +
+ +
+ )}
) } diff --git a/project/frontend/src/components/Header.tsx b/project/frontend/src/components/Header.tsx index 910f104..ad61365 100644 --- a/project/frontend/src/components/Header.tsx +++ b/project/frontend/src/components/Header.tsx @@ -22,7 +22,7 @@ import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' -import { Plus, Download, FloppyDisk, CaretDown, Code, CheckCircle, GitBranch, Upload, FileCode, FilePy, GearSix, Trash, Info, PencilSimple, Warning, Key, User, SignOut, SquaresFour } from '@phosphor-icons/react' +import { Plus, Download, FloppyDisk, CaretDown, Code, CheckCircle, GitBranch, Upload, FileCode, FilePy, GearSix, Trash, Info, PencilSimple, Warning, Key, User, SignOut, SquaresFour, ShareNetwork } from '@phosphor-icons/react' import { toast } from 'sonner' import { ThemeToggle } from '@/components/ThemeToggle' import { validateModel, exportModel as apiExportModel } from '@/lib/api' @@ -33,6 +33,7 @@ import { useAuth } from '@/contexts/AuthContext' import ApiKeyModal from './ApiKeyModal' import { GuestLoginPrompt } from './GuestLoginPrompt' import LoginModal from './LoginModal' +import ShareDialog from './ShareDialog' export default function Header() { const navigate = useNavigate() @@ -61,6 +62,7 @@ export default function Header() { const [isNewProjectOpen, setIsNewProjectOpen] = useState(false) const [isExportOpen, setIsExportOpen] = useState(false) + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false) const [isManageProjectOpen, setIsManageProjectOpen] = useState(false) const [managingProject, setManagingProject] = useState(null) const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false) @@ -967,6 +969,18 @@ export default function Header() { Validate + {/* Share Button — visible for authenticated owners with an active project */} + {user && !isGuest && currentProject && ( + + )} + {/* Export Dropdown */} @@ -1315,6 +1329,17 @@ export default function Header() { + {/* Share Dialog */} + {currentProject && ( + + )} + {/* API Key Modal */} void + readOnly?: boolean } export default function InternalNodeConfigPanel({ @@ -26,7 +27,8 @@ export default function InternalNodeConfigPanel({ parentGroupNodeId, groupDefinitionId, internalNodeId, - onClose + onClose, + readOnly = false, }: InternalNodeConfigPanelProps) { const { nodes, @@ -171,15 +173,17 @@ export default function InternalNodeConfigPanel({ Customized - + {!readOnly && ( + + )} )} @@ -200,7 +204,7 @@ export default function InternalNodeConfigPanel({ {field.label} {field.required && *} - {isOverridden && ( + {isOverridden && !readOnly && ( + +

+ Viewers can pan, zoom, and inspect nodes. Signed-in viewers can make a copy or export the code. + Turning the link off deactivates it — turning it back on restores the same URL. +

+ + )} + + +
+ ) +} diff --git a/project/frontend/src/components/SharedProjectCanvas.tsx b/project/frontend/src/components/SharedProjectCanvas.tsx new file mode 100644 index 0000000..2137e81 --- /dev/null +++ b/project/frontend/src/components/SharedProjectCanvas.tsx @@ -0,0 +1,449 @@ +import { useState, useEffect, useRef, Suspense, lazy } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { Toaster } from 'sonner' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { ThemeToggle } from '@/components/ThemeToggle' +import { CopySimple, Download, CaretDown, FilePy, Code, EyeSlash } from '@phosphor-icons/react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { useModelBuilderStore } from '@/lib/store' +import { + fetchSharedProject, + fetchSharedArchitecture, + createProject, + saveArchitecture, + SharedProjectResponse, +} from '@/lib/projectApi' +import ConfigPanel from './ConfigPanel' +import { exportModel as apiExportModel } from '@/lib/api' +import { useAuth } from '@/contexts/AuthContext' +import LoginModal from './LoginModal' + +const Canvas = lazy(() => import('./Canvas')) + +function LoadingSpinner() { + return ( +
+
+
+ ) +} + +export default function SharedProjectCanvas() { + const { shareToken } = useParams<{ shareToken: string }>() + const navigate = useNavigate() + const { user } = useAuth() + + const { + nodes, + edges, + groupDefinitions, + selectedNodeId, + setNodes, + setEdges, + loadGroupDefinitions, + reset, + } = useModelBuilderStore() + + const [projectMeta, setProjectMeta] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [notFound, setNotFound] = useState(false) + + // Login gate state + const [showLoginModal, setShowLoginModal] = useState(false) + const [pendingAction, setPendingAction] = useState<'copy' | 'export' | null>(null) + + // Export dialog state (mirrors Header.tsx export dialog) + const [isExportOpen, setIsExportOpen] = useState(false) + const [exportCode, setExportCode] = useState<{ + model: string + train: string + dataset: string + config: string + zip: string + filename: string + } | null>(null) + + // A no-op ref for the palette drag handler (not used in read-only mode) + const noopRegister = useRef((_: (blockType: string) => void) => {}) + + // Load shared project on mount + useEffect(() => { + if (!shareToken) return + + const load = async () => { + setIsLoading(true) + try { + const [meta, arch] = await Promise.all([ + fetchSharedProject(shareToken), + fetchSharedArchitecture(shareToken), + ]) + setProjectMeta(meta) + setNodes(arch.nodes || []) + setEdges(arch.edges || []) + if (arch.groupDefinitions && arch.groupDefinitions.length > 0) { + loadGroupDefinitions(arch.groupDefinitions) + } + } catch { + setNotFound(true) + } finally { + setIsLoading(false) + } + } + + load() + + // Cleanup: reset store when leaving the page + return () => { + reset() + } + }, [shareToken]) + + // After login completes, resume the pending action + useEffect(() => { + if (!user || !pendingAction) return + setShowLoginModal(false) + if (pendingAction === 'copy') { + handleMakeCopy() + } else if (pendingAction === 'export') { + handleExport() + } + setPendingAction(null) + }, [user]) + + const requireAuth = (action: 'copy' | 'export') => { + if (!user) { + setPendingAction(action) + setShowLoginModal(true) + return false + } + return true + } + + const handleMakeCopy = async () => { + if (!requireAuth('copy')) return + if (!projectMeta) return + + const loadingToast = toast.loading('Copying project to your workspace...') + try { + const newProject = await createProject({ + name: `${projectMeta.name} (copy)`, + description: projectMeta.description, + framework: projectMeta.framework, + }) + await saveArchitecture(newProject.id, nodes, edges, groupDefinitions) + toast.dismiss(loadingToast) + toast.success('Project copied!', { + description: `"${newProject.name}" is now in your workspace`, + }) + navigate(`/project/${newProject.id}`) + } catch (error) { + toast.dismiss(loadingToast) + toast.error('Failed to copy project', { + description: error instanceof Error ? error.message : 'Unknown error', + }) + } + } + + const handleExport = async () => { + if (!requireAuth('export')) return + if (!projectMeta) return + + if (nodes.length === 0) { + toast.error('No blocks to export') + return + } + + const loadingToast = toast.loading('Generating code...') + try { + const result = await apiExportModel({ + nodes: nodes.map((node) => ({ + id: node.id, + type: node.data.blockType, + data: node.data, + position: node.position, + })), + edges: edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle || '', + targetHandle: edge.targetHandle || '', + })), + format: projectMeta.framework, + projectName: projectMeta.name, + groupDefinitions: Array.from(groupDefinitions.values()).map((def) => ({ + id: def.id, + name: def.name, + description: def.description, + category: def.category, + color: def.color, + internal_structure: { + nodes: def.internalNodes, + edges: def.internalEdges, + portMappings: def.portMappings, + }, + createdAt: def.createdAt, + updatedAt: def.updatedAt, + })), + }) + + toast.dismiss(loadingToast) + + if (result.success && result.data) { + setExportCode({ + model: result.data.files['model.py'], + train: result.data.files['train.py'], + dataset: result.data.files['dataset.py'], + config: result.data.files['config.py'], + zip: result.data.zip, + filename: result.data.filename, + }) + setIsExportOpen(true) + toast.success(`${result.data.framework.toUpperCase()} code generated!`) + } else { + toast.error('Code generation failed', { + description: typeof result.error === 'string' ? result.error : 'Unknown error', + }) + } + } catch (error) { + toast.dismiss(loadingToast) + toast.error('Code generation failed', { + description: error instanceof Error ? error.message : 'Unknown error', + }) + } + } + + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text) + toast.success(`${label} copied to clipboard!`) + } + + const downloadFile = (content: string, filename: string) => { + const blob = new Blob([content], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + + if (isLoading) { + return + } + + if (notFound || !projectMeta) { + return ( +
+ +

Link not found

+

+ This shared link is no longer active or does not exist. The owner may have disabled sharing. +

+ +
+ ) + } + + return ( +
+ {/* Shared-view header */} +
+
+
navigate('/')} + > + VisionForge Logo + VisionForge +
+ +
+ {projectMeta.name} + + {projectMeta.framework} + + {projectMeta.owner_display_name && ( + + · Shared by{' '} + + {projectMeta.owner_display_name} + + + )} + + Read-only + +
+
+ +
+ + + + + {/* Export dropdown */} + + + + + + + Export Options + + + +
+
+ {projectMeta.framework.toUpperCase()} Code +
+
+ Generate model.py, train.py, dataset.py, config.py +
+
+
+
+
+ + {/* Export dialog — identical to Header.tsx */} + + + + Export {projectMeta.framework.toUpperCase()} Code + + + Copy individual files or download all as ZIP + + + {exportCode && ( +
+
+ +
+ + + + model.py + train.py + dataset.py + config.py + + {(['model', 'train', 'dataset', 'config'] as const).map((key) => ( + +
+
+
+                              {exportCode[key]}
+                            
+
+
+
+ + +
+
+ ))} +
+
+ )} +
+
+
+
+ + {/* Read-only canvas + config panel */} +
+ }> + {}} + onRegisterAddNode={noopRegister.current} + readOnly={true} + /> + + {selectedNodeId && } +
+ + + + {/* Login gate */} + { + setShowLoginModal(open) + if (!open) setPendingAction(null) + }} + required={false} + /> +
+ ) +} diff --git a/project/frontend/src/lib/projectApi.ts b/project/frontend/src/lib/projectApi.ts index 3d9a7f4..b03bf45 100644 --- a/project/frontend/src/lib/projectApi.ts +++ b/project/frontend/src/lib/projectApi.ts @@ -9,10 +9,20 @@ export interface ProjectResponse { name: string description: string framework: 'pytorch' | 'tensorflow' + share_token: string | null + is_shared: boolean created_at: string updated_at: string } +export interface SharedProjectResponse { + name: string + description: string + framework: 'pytorch' | 'tensorflow' + owner_display_name: string | null + share_token: string +} + export interface ProjectDetailResponse extends ProjectResponse { architecture?: { id: string @@ -188,6 +198,79 @@ export async function loadArchitecture(projectId: string): Promise<{ return await response.json() } +/** + * Fetch a shared project's metadata (public — no auth required) + */ +export async function fetchSharedProject(shareToken: string): Promise { + const response = await fetch(`${API_BASE_URL}/shared/${shareToken}/`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + if (!response.ok) { + throw new Error(`Shared project not found: ${response.statusText}`) + } + + return await response.json() +} + +/** + * Fetch a shared project's architecture (public — no auth required) + */ +export async function fetchSharedArchitecture(shareToken: string): Promise<{ + nodes: Node[] + edges: Edge[] + groupDefinitions?: any[] +}> { + const response = await fetch(`${API_BASE_URL}/shared/${shareToken}/architecture/`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + if (!response.ok) { + throw new Error(`Failed to load shared architecture: ${response.statusText}`) + } + + return await response.json() +} + +/** + * Enable public sharing for a project (owner only) + */ +export async function enableSharing( + projectId: string +): Promise<{ share_token: string; is_shared: boolean }> { + const headers = await getAuthHeaders() + + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/share/`, { + method: 'POST', + headers, + }) + + if (!response.ok) { + throw new Error(`Failed to enable sharing: ${response.statusText}`) + } + + return await response.json() +} + +/** + * Disable public sharing for a project (owner only). + * The share token is preserved so re-enabling restores the same URL. + */ +export async function disableSharing(projectId: string): Promise { + const headers = await getAuthHeaders() + + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/unshare/`, { + method: 'DELETE', + headers, + }) + + if (!response.ok) { + throw new Error(`Failed to disable sharing: ${response.statusText}`) + } +} + /** * Convert backend project to frontend Project type */ @@ -205,5 +288,7 @@ export function convertToFrontendProject( edges, createdAt: new Date(backendProject.created_at).getTime(), updatedAt: new Date(backendProject.updated_at).getTime(), + share_token: backendProject.share_token ?? null, + is_shared: backendProject.is_shared ?? false, } } diff --git a/project/frontend/src/lib/types.ts b/project/frontend/src/lib/types.ts index 42a30d8..f5b9d06 100644 --- a/project/frontend/src/lib/types.ts +++ b/project/frontend/src/lib/types.ts @@ -76,6 +76,8 @@ export interface Project { edges: any[] createdAt: number updatedAt: number + share_token?: string | null + is_shared?: boolean } export interface ValidationError {