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, Loader2, LogOut, User, Settings, Key, X, Eye, EyeOff } from 'lucide-react'; import useFlowStore, { type FSItem, type BlueprintDocument, type FileMeta } from '../store/flowStore'; import { useAuthStore } from '../store/authStore'; interface LeftSidebarProps { isOpen: boolean; onToggle: () => void; } const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { const [activeTab, setActiveTab] = useState<'project' | 'files' | 'archive'>('project'); const { archivedNodes, removeFromArchive, theme, files, uploadingFileIds, projectTree, currentBlueprintPath, saveStatus, refreshProjectTree, loadArchivedNodes, refreshFiles, uploadFile, deleteFile, readBlueprintFile, loadBlueprint, saveBlueprintFile, saveCurrentBlueprint, createProjectFolder, renameProjectItem, deleteProjectItem, setCurrentBlueprintPath, clearBlueprint } = useFlowStore(); const { user, logout } = useAuthStore(); const { setViewport, getViewport } = useReactFlow(); const isDark = theme === 'dark'; const fileInputRef = useRef(null); const fileUploadRef = useRef(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item?: FSItem } | null>(null); const [currentFolder, setCurrentFolder] = useState('.'); const [dragItem, setDragItem] = useState(null); const [showSaveStatus, setShowSaveStatus] = useState(false); const [expanded, setExpanded] = useState>(() => new Set(['.'])); const [fileProvider, _setFileProvider] = useState<'local' | 'openai' | 'google'>('local'); const [openaiPurpose, _setOpenaiPurpose] = useState('user_data'); // Suppress unused warnings - these may be used in future void _setFileProvider; void _setOpenaiPurpose; const [fileSearch, setFileSearch] = useState(''); // User Settings Modal State const [showUserSettings, setShowUserSettings] = useState(false); const [openaiApiKey, setOpenaiApiKey] = useState(''); const [geminiApiKey, setGeminiApiKey] = useState(''); const [showOpenaiKey, setShowOpenaiKey] = useState(false); const [showGeminiKey, setShowGeminiKey] = useState(false); const [savingKeys, setSavingKeys] = useState(false); const [keysMessage, setKeysMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const { getAuthHeader } = useAuthStore(); // Load API keys when settings modal opens useEffect(() => { if (showUserSettings) { fetch('/api/auth/api-keys', { headers: { ...getAuthHeader() }, }) .then(res => res.json()) .then(data => { setOpenaiApiKey(data.openai_api_key || ''); setGeminiApiKey(data.gemini_api_key || ''); }) .catch(() => {}); } }, [showUserSettings, getAuthHeader]); const handleSaveApiKeys = async () => { setSavingKeys(true); setKeysMessage(null); try { const res = await fetch('/api/auth/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, body: JSON.stringify({ openai_api_key: openaiApiKey.includes('*') ? undefined : openaiApiKey, gemini_api_key: geminiApiKey.includes('*') ? undefined : geminiApiKey, }), }); if (res.ok) { setKeysMessage({ type: 'success', text: 'API keys saved successfully!' }); // Reload masked keys const data = await fetch('/api/auth/api-keys', { headers: { ...getAuthHeader() }, }).then(r => r.json()); setOpenaiApiKey(data.openai_api_key || ''); setGeminiApiKey(data.gemini_api_key || ''); } else { setKeysMessage({ type: 'error', text: 'Failed to save API keys' }); } } catch { setKeysMessage({ type: 'error', text: 'Network error' }); } setSavingKeys(false); }; 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]); // Load files when entering files tab useEffect(() => { if (activeTab === 'files') { refreshFiles().catch(() => {}); } }, [activeTab, refreshFiles]); // 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) => { // For .json files, show name without extension const isJsonFile = item.type === 'file' && item.name.endsWith('.json'); const displayName = isJsonFile ? item.name.replace(/\.json$/, '') : item.name; const newName = promptName('Rename to', displayName); if (!newName || newName === displayName) return; // Add .json extension back for json files const finalName = isJsonFile ? `${newName}.json` : newName; await renameProjectItem(item.path, finalName); }; 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 || ''}/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(); }; // Files tab handlers const handleFilesUpload = async (list: FileList) => { let ok = 0; let failed: string[] = []; for (const f of Array.from(list)) { try { await uploadFile(f, { provider: fileProvider, purpose: fileProvider === 'openai' ? openaiPurpose : undefined, }); ok += 1; } catch (e) { console.error(e); failed.push(`${f.name}: ${(e as Error).message}`); } } await refreshFiles(); if (failed.length) { alert(`Some files failed:\n${failed.join('\n')}`); } else if (ok > 0) { // Optional: brief feedback console.info(`Uploaded ${ok} file(s)`); } }; const filteredFiles = useMemo(() => { const q = fileSearch.trim().toLowerCase(); if (!q) return files; // Only search local files; keep provider files out of filtered results return files.filter(f => !f.provider && f.name.toLowerCase().includes(q)); }, [files, fileSearch]); const handleFilesInputChange = async (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { await handleFilesUpload(files); e.target.value = ''; } }; const handleDownloadFile = (file: FileMeta) => { const url = `${import.meta.env.VITE_BACKEND_URL || ''}/api/files/download?user=test&file_id=${encodeURIComponent(file.id)}`; const a = document.createElement('a'); a.href = url; a.download = file.name; a.click(); }; const formatSize = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; }; // 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) => { 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 (
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)} >
{item.type === 'folder' ? ( ) : ( )} {item.type === 'folder' ? : } {stripJson(item.name)}
{item.type === 'folder' && isExpanded && item.children && item.children.length > 0 && (
{renderTree(item.children, depth + 1)}
)}
); }); }, [isDark, currentBlueprintPath, expanded, handleLoadFile]); if (!isOpen) { return (
{/* Icons when collapsed */}
); } return (
setContextMenu(null)} onContextMenu={(e) => { // Default empty-area context menu if (activeTab === 'project') { openContextMenu(e); } }} > {/* Header */}

Workspace

{user && ( {user.username} )}
{user && ( )}
{/* User Settings Modal */} {showUserSettings && (
setShowUserSettings(false)}>
e.stopPropagation()} > {/* Header */}

User Settings

{/* Content */}
{/* User Info */}

{user?.username}

{user?.email}

{/* API Keys Section */}

API Keys

{/* OpenAI API Key */}
setOpenaiApiKey(e.target.value)} placeholder="sk-..." className={`w-full px-3 py-2 pr-10 rounded-lg text-sm ${ isDark ? 'bg-gray-700 border-gray-600 text-white placeholder-gray-500' : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400' } border focus:outline-none focus:ring-2 focus:ring-blue-500`} />
{/* Gemini API Key */}
setGeminiApiKey(e.target.value)} placeholder="AI..." className={`w-full px-3 py-2 pr-10 rounded-lg text-sm ${ isDark ? 'bg-gray-700 border-gray-600 text-white placeholder-gray-500' : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400' } border focus:outline-none focus:ring-2 focus:ring-blue-500`} />
{/* Save Button */} {/* Message */} {keysMessage && (

{keysMessage.text}

)}
{/* Footer */}
)} {/* Tabs */}
{/* Content Area */}
{activeTab === 'project' && (
{ e.preventDefault(); e.stopPropagation(); }} > {saveStatus === 'saved' ? 'Saved' : saveStatus === 'saving' ? 'Saving...' : saveStatus === 'error' ? 'Save failed' : ''}
{!currentBlueprintPath && (
No file open; Save will create a new file.
)}
{ e.preventDefault(); e.stopPropagation(); }} />
{ 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 ? (

No files. Right-click to add.

) : (
{renderTree(projectTree, 0)}
)}
)} {activeTab === 'files' && (
{ if (e.dataTransfer.types.includes('Files')) e.preventDefault(); }} onDrop={async (e) => { if (e.dataTransfer.files?.length) { e.preventDefault(); await handleFilesUpload(e.dataTransfer.files); } }} >
Drag files here or click upload
setFileSearch(e.target.value)} className={`flex-1 text-sm border rounded px-2 py-1 ${isDark ? 'bg-gray-800 border-gray-700 text-gray-100 placeholder-gray-500' : 'bg-white border-gray-200 text-gray-800 placeholder-gray-400'}`} placeholder="Search files by name..." /> {fileSearch && ( )}
{files.length === 0 && (uploadingFileIds?.length || 0) === 0 ? (

No files uploaded yet.

) : filteredFiles.length === 0 && (uploadingFileIds?.length || 0) === 0 ? (

No files match your search.

) : (
{filteredFiles.map(f => (
{f.name} {formatSize(f.size)} • {new Date(f.created_at * 1000).toLocaleString()} {f.provider && ( Provider: {f.provider === 'openai' ? 'OpenAI' : f.provider === 'google' ? 'Gemini' : f.provider} )}
))} {uploadingFileIds && uploadingFileIds.length > 0 && (
Uploading {uploadingFileIds.length} file{uploadingFileIds.length > 1 ? 's' : ''}…
)}
)}
)} {activeTab === 'archive' && (
{archivedNodes.length === 0 ? (

No archived nodes.
Right-click a node → "Add to Archive"

) : ( <>

Drag to canvas to create a copy

{archivedNodes.map((archived) => (
handleDragStart(e, archived.id)} className={`p-2 border rounded-md cursor-grab transition-colors group ${ isDark ? '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?'…':''}`} >
{archived.label}
{archived.model}
))} )}
)}
{/* Context Menu */} {contextMenu && (
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 = ( <> ); if (!item) { return commonNew; } if (item.type === 'file') { return ( <> {commonNew} ); } // folder return ( <> {commonNew} ); })()}
)}
); }; export default LeftSidebar;