summaryrefslogtreecommitdiff
path: root/frontend/src/components/LeftSidebar.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/LeftSidebar.tsx')
-rw-r--r--frontend/src/components/LeftSidebar.tsx481
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>
);
};