diff options
Diffstat (limited to 'frontend/src/components/LeftSidebar.tsx')
| -rw-r--r-- | frontend/src/components/LeftSidebar.tsx | 481 |
1 files changed, 472 insertions, 9 deletions
diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx index 1eaa62c..1b7ccb2 100644 --- a/frontend/src/components/LeftSidebar.tsx +++ b/frontend/src/components/LeftSidebar.tsx @@ -1,6 +1,10 @@ -import React, { useState } from 'react'; -import { Folder, FileText, Archive, ChevronLeft, ChevronRight, Trash2, MessageSquare } from 'lucide-react'; -import useFlowStore from '../store/flowStore'; +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { useReactFlow } from 'reactflow'; +import { + Folder, FileText, Archive, ChevronLeft, ChevronRight, Trash2, MessageSquare, + MoreVertical, Download, Upload, Plus, RefreshCw, Edit3 +} from 'lucide-react'; +import useFlowStore, { type FSItem, type BlueprintDocument } from '../store/flowStore'; interface LeftSidebarProps { isOpen: boolean; @@ -9,14 +13,325 @@ interface LeftSidebarProps { const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { const [activeTab, setActiveTab] = useState<'project' | 'files' | 'archive'>('project'); - const { archivedNodes, removeFromArchive, createNodeFromArchive, theme } = useFlowStore(); + const { + archivedNodes, + removeFromArchive, + createNodeFromArchive, + theme, + projectTree, + currentBlueprintPath, + saveStatus, + refreshProjectTree, + loadArchivedNodes, + readBlueprintFile, + loadBlueprint, + saveBlueprintFile, + saveCurrentBlueprint, + createProjectFolder, + renameProjectItem, + deleteProjectItem, + setCurrentBlueprintPath, + serializeBlueprint, + clearBlueprint + } = useFlowStore(); + const { setViewport, getViewport } = useReactFlow(); const isDark = theme === 'dark'; + const fileInputRef = useRef<HTMLInputElement | null>(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item?: FSItem } | null>(null); + const [currentFolder, setCurrentFolder] = useState<string>('.'); + const [dragItem, setDragItem] = useState<FSItem | null>(null); + const [showSaveStatus, setShowSaveStatus] = useState(false); + const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['.'])); const handleDragStart = (e: React.DragEvent, archiveId: string) => { e.dataTransfer.setData('archiveId', archiveId); e.dataTransfer.effectAllowed = 'copy'; }; + const joinPath = (folder: string, name: string) => { + if (!folder || folder === '.' || folder === '/') return name; + return `${folder.replace(/\\/g, '/').replace(/\/+$/, '')}/${name}`; + }; + + const stripJson = (name: string) => name.endsWith('.json') ? name.slice(0, -5) : name; + + const findChildren = useCallback((folder: string, list: FSItem[] = projectTree): FSItem[] => { + const norm = folder.replace(/\\/g, '/').replace(/^\.\/?/, ''); + if (folder === '.' || folder === '' || folder === '/') return list; + for (const item of list) { + if (item.type === 'folder') { + if (item.path === norm) return item.children || []; + const found = findChildren(folder, item.children || []); + if (found) return found; + } + } + return []; + }, [projectTree]); + + const ensureUniqueName = useCallback((base: string, targetFolder: string, isFolder: boolean) => { + const siblings = findChildren(targetFolder).map(i => i.name); + const ext = isFolder ? '' : '.json'; + const rawBase = stripJson(base); + let candidate = rawBase + ext; + let idx = 1; + while (siblings.includes(candidate)) { + idx += 1; + candidate = `${rawBase} (${idx})${ext}`; + } + return candidate; + }, [findChildren]); + + // Load project tree on mount and when tab switches to project + useEffect(() => { + if (activeTab === 'project') { + refreshProjectTree().catch(() => {}); + } + }, [activeTab, refreshProjectTree]); + + // Load archived nodes on mount + useEffect(() => { + loadArchivedNodes().catch(() => {}); + }, [loadArchivedNodes]); + + // Context menu handlers + const openContextMenu = (e: React.MouseEvent, item?: FSItem) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, item }); + }; + + const closeContextMenu = () => setContextMenu(null); + + const promptName = (message: string, defaultValue: string) => { + const val = window.prompt(message, defaultValue); + return val?.trim() || null; + }; + + const handleCreateFolder = async (base: string) => { + const input = promptName('Folder name', 'new-folder'); + if (!input) return; + const name = ensureUniqueName(input, base, true); + await createProjectFolder(joinPath(base, name)); + }; + + const handleNewBlueprint = async (base: string) => { + const input = promptName('Blueprint file name', 'untitled'); + if (!input) return; + const name = ensureUniqueName(input, base, false); + const path = joinPath(base, name); + // Create empty blueprint and save immediately + const empty: BlueprintDocument = { + version: 1, + nodes: [], + edges: [], + viewport: getViewport(), + theme, + }; + await saveBlueprintFile(path, empty.viewport); + await loadBlueprint(empty); + setCurrentBlueprintPath(path); + }; + + const handleRename = async (item: FSItem) => { + const newName = promptName('Rename to', item.name); + if (!newName || newName === item.name) return; + await renameProjectItem(item.path, newName); + }; + + const handleDelete = async (item: FSItem) => { + const currentPath = currentBlueprintPath; + const isDeletingOpen = + currentPath === item.path || + (item.type === 'folder' && currentPath && (currentPath === item.path || currentPath.startsWith(`${item.path}/`))); + const ok = window.confirm( + isDeletingOpen + ? `The opened blueprint is in this ${item.type}. Delete and clear canvas?` + : `Delete ${item.name}?` + ); + if (!ok) return; + await deleteProjectItem(item.path, item.type === 'folder'); + if (isDeletingOpen) { + clearBlueprint(); + } + await refreshProjectTree(); + }; + + const handleLoadFile = async (item: FSItem) => { + if (item.type !== 'file') return; + try { + const doc = await readBlueprintFile(item.path); + const vp = loadBlueprint(doc); + setCurrentBlueprintPath(item.path); + if (vp) { + setViewport(vp); + } + } catch (e) { + console.error(e); + alert('Not a valid blueprint JSON.'); + } + }; + + const handleDownload = async (item: FSItem) => { + if (item.type !== 'file') return; + const url = `${import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'}/api/projects/download?user=test&path=${encodeURIComponent(item.path)}`; + const a = document.createElement('a'); + a.href = url; + a.download = item.name; + a.click(); + }; + + const handleUploadClick = () => fileInputRef.current?.click(); + + const promptForPath = (base: string) => { + const input = window.prompt('Save as (filename without extension)', 'untitled')?.trim(); + if (!input) return null; + const name = ensureUniqueName(input, base, false); + return joinPath(base, name); + }; + + const handleSave = async () => { + let path = currentBlueprintPath; + if (!path) { + const p = promptForPath(currentFolder); + if (!p) return; + path = p; + setCurrentBlueprintPath(path); + } + const viewport = getViewport(); + await saveCurrentBlueprint(path, viewport); + }; + + const handleUploadFiles = async (files: FileList, targetFolder: string) => { + for (const file of Array.from(files)) { + if (!file.name.toLowerCase().endsWith('.json')) continue; + const text = await file.text(); + try { + const json = JSON.parse(text); + const viewport = json.viewport; + const uniqueName = ensureUniqueName(file.name, targetFolder, false); + await saveBlueprintFile(joinPath(targetFolder, uniqueName), viewport); + } catch { + // skip invalid json + } + } + await refreshProjectTree(); + }; + + // Fade-out for "Saved" indicator + useEffect(() => { + if (saveStatus === 'saved') { + setShowSaveStatus(true); + const t = window.setTimeout(() => setShowSaveStatus(false), 1000); + return () => window.clearTimeout(t); + } + if (saveStatus === 'saving' || saveStatus === 'error') { + setShowSaveStatus(true); + return; + } + setShowSaveStatus(false); + }, [saveStatus]); + + const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => { + const files = e.target.files; + if (files && files.length > 0) { + await handleUploadFiles(files, currentFolder); + e.target.value = ''; + } + }; + + // Drag move blueprint into folder + const onItemDragStart = (e: React.DragEvent, item: FSItem) => { + setDragItem(item); + e.dataTransfer.effectAllowed = 'move'; + }; + const onItemDragOver = (e: React.DragEvent, item: FSItem) => { + if (item.type === 'folder') { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } + }; + const onItemDrop = async (e: React.DragEvent, target: FSItem) => { + e.preventDefault(); + if (!dragItem || target.type !== 'folder') return; + const newPath = joinPath(target.path, dragItem.name); + if (newPath === dragItem.path) return; + await renameProjectItem(dragItem.path, undefined, newPath); + setDragItem(null); + }; + + const toggleFolder = (path: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const renderTree = useCallback((items: FSItem[], depth = 0) => { + return items.map(item => { + const isActive = currentBlueprintPath === item.path; + const isExpanded = expanded.has(item.path); + const padding = depth * 12; + const hasChildren = (item.children?.length || 0) > 0; + return ( + <div key={item.path}> + <div + className={`flex items-center justify-between px-2 py-1 rounded cursor-pointer ${ + isActive + ? isDark ? 'bg-blue-900/40 border border-blue-700' : 'bg-blue-50 border-blue-200 border' + : isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-100' + }`} + style={{ paddingLeft: padding + 8 }} + onContextMenu={(e) => openContextMenu(e, item)} + onClick={() => { + if (item.type === 'folder') { + toggleFolder(item.path); + setCurrentFolder(item.path); + } else { + setCurrentFolder(item.path.split('/').slice(0, -1).join('/') || '.'); + } + }} + onDoubleClick={() => { + if (item.type === 'file') { + handleLoadFile(item); + setCurrentFolder(item.path.split('/').slice(0, -1).join('/') || '.'); + } + }} + draggable + onDragStart={(e) => onItemDragStart(e, item)} + onDragOver={(e) => onItemDragOver(e, item)} + onDrop={(e) => onItemDrop(e, item)} + > + <div className="flex items-center gap-2"> + {item.type === 'folder' ? ( + <button + className="w-4 text-left" + onClick={(e) => { e.stopPropagation(); toggleFolder(item.path); }} + title={hasChildren ? undefined : 'Empty folder'} + > + {isExpanded ? '▾' : '▸'} + </button> + ) : ( + <span className="w-4" /> + )} + {item.type === 'folder' ? <Folder size={14} /> : <FileText size={14} />} + <span className="truncate">{stripJson(item.name)}</span> + </div> + <button className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700" onClick={(e) => { e.stopPropagation(); openContextMenu(e as any, item); }}> + <MoreVertical size={14} /> + </button> + </div> + {item.type === 'folder' && isExpanded && item.children && item.children.length > 0 && ( + <div> + {renderTree(item.children, depth + 1)} + </div> + )} + </div> + ); + }); + }, [isDark, currentBlueprintPath, expanded, handleLoadFile]); + if (!isOpen) { return ( <div className={`border-r h-screen flex flex-col items-center py-4 w-12 z-10 transition-all duration-300 ${ @@ -40,9 +355,18 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { } return ( - <div className={`w-64 border-r h-screen flex flex-col shadow-xl z-10 transition-all duration-300 ${ + <div + className={`w-[14%] min-w-[260px] max-w-[360px] border-r h-screen flex flex-col shadow-xl z-10 transition-all duration-300 ${ isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white' - }`}> + }`} + onClick={() => setContextMenu(null)} + onContextMenu={(e) => { + // Default empty-area context menu + if (activeTab === 'project') { + openContextMenu(e); + } + }} + > {/* Header */} <div className={`p-3 border-b flex justify-between items-center ${ isDark ? 'border-gray-700 bg-gray-900' : 'border-gray-200 bg-gray-50' @@ -93,9 +417,103 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { {/* Content Area */} <div className={`flex-1 overflow-y-auto p-4 text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> {activeTab === 'project' && ( - <div className="flex flex-col items-center justify-center h-full opacity-50"> - <Folder size={48} className="mb-2" /> - <p>Project settings coming soon</p> + <div + className="space-y-2" + > + <div + className="flex items-center gap-2 mb-2" + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }} + > + <button + onClick={() => refreshProjectTree()} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="Refresh" + > + <RefreshCw size={14} /> + </button> + <button + onClick={() => handleNewBlueprint(currentFolder)} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="New Blueprint" + > + <Plus size={14} /> + </button> + <button + onClick={() => handleCreateFolder(currentFolder)} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="New Folder" + > + <Folder size={14} /> + </button> + <button + onClick={handleUploadClick} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="Upload Blueprint" + > + <Upload size={14} /> + </button> + <button + onClick={handleSave} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="Save (Ctrl+S)" + > + <Edit3 size={14} /> + </button> + <span + className={`text-xs transition-opacity duration-300 ${ + saveStatus === 'saved' ? 'text-green-500' : saveStatus === 'saving' ? 'text-blue-500' : saveStatus === 'error' ? 'text-red-500' : isDark ? 'text-gray-500' : 'text-gray-400' + }`} + style={{ opacity: showSaveStatus && saveStatus !== 'idle' ? 1 : 0 }} + > + {saveStatus === 'saved' ? 'Saved' : saveStatus === 'saving' ? 'Saving...' : saveStatus === 'error' ? 'Save failed' : ''} + </span> + <input + ref={fileInputRef} + type="file" + accept=".json,application/json" + multiple + className="hidden" + onChange={handleFileInputChange} + /> + </div> + {!currentBlueprintPath && ( + <div className={`text-xs italic ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + No file open; Save will create a new file. + </div> + )} + + <div + className={`${isDark ? 'border-gray-700' : 'border-gray-200'} border-t border-dashed mx-1`} + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }} + /> + + <div + onContextMenu={(e) => { + if (activeTab === 'project') { + openContextMenu(e); + } + }} + onDragOver={(e) => { + if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); } + }} + onDrop={async (e) => { + if (e.dataTransfer.files?.length) { + e.preventDefault(); + await handleUploadFiles(e.dataTransfer.files, currentFolder); + } + }} + > + {projectTree.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-40 opacity-50"> + <Folder size={32} className="mb-2" /> + <p className="text-xs text-center">No files. Right-click to add.</p> + </div> + ) : ( + <div className="space-y-1"> + {renderTree(projectTree, 0)} + </div> + )} + </div> </div> )} {activeTab === 'files' && ( @@ -127,6 +545,7 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { ? 'bg-gray-700 border-gray-600 hover:bg-gray-600 hover:border-gray-500' : 'bg-gray-50 border-gray-200 hover:bg-gray-100 hover:border-gray-300' }`} + title={`Label: ${archived.label}\nModel: ${archived.model}\nSystem: ${archived.systemPrompt || '(empty)'}\nUser: ${(archived.userPrompt || '').slice(0,80)}${(archived.userPrompt || '').length>80?'…':''}\nResp: ${(archived.response || '').slice(0,80)}${(archived.response || '').length>80?'…':''}`} > <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> @@ -151,6 +570,50 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { </div> )} </div> + + {/* Context Menu */} + {contextMenu && ( + <div + className={`fixed z-50 rounded-md shadow-lg border py-1 text-sm ${isDark ? 'bg-gray-800 border-gray-700 text-gray-200' : 'bg-white border-gray-200 text-gray-700'}`} + style={{ top: contextMenu.y, left: contextMenu.x }} + onClick={(e) => e.stopPropagation()} + > + {(() => { + const item = contextMenu.item; + const targetFolder = item + ? (item.type === 'folder' ? item.path : item.path.split('/').slice(0, -1).join('/') || '.') + : '.'; // empty area => root + const commonNew = ( + <> + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleCreateFolder(targetFolder); }}>New Folder</button> + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleNewBlueprint(targetFolder); }}>New Blueprint</button> + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleUploadClick(); }}>Upload</button> + </> + ); + if (!item) { + return commonNew; + } + if (item.type === 'file') { + return ( + <> + {commonNew} + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleDownload(item); }}>Download</button> + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleRename(item); }}>Rename</button> + <button className="block w-full text-left px-3 py-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/40" onClick={() => { closeContextMenu(); handleDelete(item); }}>Delete</button> + </> + ); + } + // folder + return ( + <> + {commonNew} + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleRename(item); }}>Rename</button> + <button className="block w-full text-left px-3 py-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/40" onClick={() => { closeContextMenu(); handleDelete(item); }}>Delete</button> + </> + ); + })()} + </div> + )} </div> ); }; |
