From c5a9f1fda804b493d1345b20e9838a3e7671fe6e Mon Sep 17 00:00:00 2001 From: MrZang101 <51847523+MrZang101@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:35:32 +0000 Subject: [PATCH 1/3] feat: add multi-project dashboard replacing quickstart modal Adds a project dashboard overlay that shows all saved projects with the ability to open, create, delete, and switch between them. Replaces the old WelcomeModal as the initial view. - Extend WorkflowSaveConfig with createdAt, updatedAt, nodeCount, edgeCount - Add deleteSaveConfig and getAllProjectsForDashboard localStorage helpers - Create /api/workflow-load POST route to load workflow JSON from disk - Add showDashboard state, openProject, deleteProject to Zustand store - Create ProjectCard and ProjectDashboard components - Wire dashboard into WorkflowCanvas (replaces quickstart) and Header (grid icon) Co-Authored-By: Claude Opus 4.6 --- src/app/api/workflow-load/route.ts | 54 ++++ src/components/Header.tsx | 10 + src/components/WorkflowCanvas.tsx | 29 +- src/components/dashboard/ProjectCard.tsx | 131 ++++++++ src/components/dashboard/ProjectDashboard.tsx | 291 ++++++++++++++++++ src/store/utils/localStorage.ts | 60 +++- src/store/workflowStore.ts | 51 +++ src/types/workflow.ts | 4 + 8 files changed, 613 insertions(+), 17 deletions(-) create mode 100644 src/app/api/workflow-load/route.ts create mode 100644 src/components/dashboard/ProjectCard.tsx create mode 100644 src/components/dashboard/ProjectDashboard.tsx diff --git a/src/app/api/workflow-load/route.ts b/src/app/api/workflow-load/route.ts new file mode 100644 index 00000000..baa968b2 --- /dev/null +++ b/src/app/api/workflow-load/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import * as fs from "fs/promises"; +import * as path from "path"; + +// POST: Load workflow JSON from disk by path +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { directoryPath, filename } = body; + + if (!directoryPath || !filename) { + return NextResponse.json( + { success: false, error: "Missing required fields: directoryPath and filename" }, + { status: 400 } + ); + } + + // Same sanitization as the save route + const safeName = filename.replace(/[^a-zA-Z0-9-_]/g, "_"); + const filePath = path.join(directoryPath, `${safeName}.json`); + + // Prevent path traversal + const resolvedDir = path.resolve(directoryPath); + const resolvedFile = path.resolve(filePath); + if (!resolvedFile.startsWith(resolvedDir)) { + return NextResponse.json( + { success: false, error: "Invalid file path" }, + { status: 400 } + ); + } + + const content = await fs.readFile(resolvedFile, "utf-8"); + const workflow = JSON.parse(content); + + return NextResponse.json({ success: true, workflow }); + } catch (error) { + const isNotFound = + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT"; + + return NextResponse.json( + { + success: false, + error: isNotFound + ? "Workflow file not found" + : error instanceof Error + ? error.message + : "Failed to load workflow", + }, + { status: isNotFound ? 404 : 500 } + ); + } +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1a0e58f6..a88ff5b0 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -72,6 +72,7 @@ export function Header() { revertToSnapshot, shortcutsDialogOpen, setShortcutsDialogOpen, + setShowDashboard, } = useWorkflowStore(); const [showProjectModal, setShowProjectModal] = useState(false); @@ -222,6 +223,15 @@ export function Header() {

Node Banana

+
{isProjectConfigured ? ( diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 2ae6d4fd..4f5a65dc 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -56,6 +56,7 @@ import { detectAndSplitGrid } from "@/utils/gridSplitter"; import { logger } from "@/utils/logger"; import { WelcomeModal } from "./quickstart"; import { ProjectSetupModal } from "./ProjectSetupModal"; +import { ProjectDashboard } from "./dashboard/ProjectDashboard"; import { ChatPanel } from "./ChatPanel"; import { EditOperation } from "@/lib/chat/editOperations"; import { stripBinaryData } from "@/lib/chat/contextBuilder"; @@ -239,7 +240,7 @@ const findScrollableAncestor = (target: HTMLElement, deltaX: number, deltaY: num }; export function WorkflowCanvas() { - const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations, setWorkflowMetadata, canvasNavigationSettings, setShortcutsDialogOpen } = + const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, showDashboard, setShowDashboard, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations, setWorkflowMetadata, canvasNavigationSettings, setShortcutsDialogOpen } = useWorkflowStore(); const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter } = useReactFlow(); const { show: showToast } = useToast(); @@ -255,6 +256,13 @@ export function WorkflowCanvas() { // Detect if canvas is empty for showing quickstart const isCanvasEmpty = nodes.length === 0; + // Listen for dashboard:new-project event to open new project setup + useEffect(() => { + const handler = () => setShowNewProjectSetup(true); + window.addEventListener("dashboard:new-project", handler); + return () => window.removeEventListener("dashboard:new-project", handler); + }, []); + // Handle comment navigation - center viewport on target node useEffect(() => { if (navigationTarget) { @@ -1605,20 +1613,8 @@ export function WorkflowCanvas() {
)} - {/* Welcome Modal */} - {isCanvasEmpty && showQuickstart && ( - { - await loadWorkflow(workflow); - setShowQuickstart(false); - }} - onClose={() => setShowQuickstart(false)} - onNewProject={() => { - setShowQuickstart(false); - setShowNewProjectSetup(true); - }} - /> - )} + {/* Project Dashboard */} + {showDashboard && } {/* New Project Setup Modal */} {showNewProjectSetup && ( @@ -1628,10 +1624,11 @@ export function WorkflowCanvas() { onSave={(id, name, directoryPath) => { setWorkflowMetadata(id, name, directoryPath); setShowNewProjectSetup(false); + setShowDashboard(false); }} onClose={() => { setShowNewProjectSetup(false); - setShowQuickstart(true); + setShowDashboard(true); }} /> )} diff --git a/src/components/dashboard/ProjectCard.tsx b/src/components/dashboard/ProjectCard.tsx new file mode 100644 index 00000000..6a175b31 --- /dev/null +++ b/src/components/dashboard/ProjectCard.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState, useCallback } from "react"; +import type { DashboardProject } from "@/store/utils/localStorage"; + +function formatRelativeTime(timestamp: number | undefined | null): string { + if (!timestamp) return "Never saved"; + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return "Just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +} + +function formatCost(cost: number): string { + if (cost === 0) return ""; + return `$${cost.toFixed(2)}`; +} + +function truncatePath(dirPath: string, maxLen: number = 40): string { + if (dirPath.length <= maxLen) return dirPath; + const parts = dirPath.split("/"); + if (parts.length <= 2) return "..." + dirPath.slice(-maxLen); + return ".../" + parts.slice(-2).join("/"); +} + +interface ProjectCardProps { + project: DashboardProject; + isCurrent: boolean; + onOpen: (workflowId: string) => void; + onDelete: (workflowId: string) => void; +} + +export function ProjectCard({ project, isCurrent, onOpen, onDelete }: ProjectCardProps) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const handleDelete = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (showDeleteConfirm) { + onDelete(project.workflowId); + setShowDeleteConfirm(false); + } else { + setShowDeleteConfirm(true); + } + }, [showDeleteConfirm, onDelete, project.workflowId]); + + const handleCancelDelete = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setShowDeleteConfirm(false); + }, []); + + return ( + + + + ) : ( + + )} + + + + {/* Meta row */} +
+ {formatRelativeTime(project.updatedAt ?? project.lastSavedAt)} + {(project.nodeCount != null && project.nodeCount > 0) && ( + <> + · + {project.nodeCount} nodes + + )} + {project.incurredCost > 0 && ( + <> + · + {formatCost(project.incurredCost)} + + )} +
+ + ); +} diff --git a/src/components/dashboard/ProjectDashboard.tsx b/src/components/dashboard/ProjectDashboard.tsx new file mode 100644 index 00000000..d8aa497d --- /dev/null +++ b/src/components/dashboard/ProjectDashboard.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { useState, useCallback, useRef, useMemo } from "react"; +import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore"; +import { getAllProjectsForDashboard } from "@/store/utils/localStorage"; +import { ProjectCard } from "./ProjectCard"; +import { TemplateExplorerView } from "../quickstart/TemplateExplorerView"; +import { PromptWorkflowView } from "../quickstart/PromptWorkflowView"; + +type DashboardView = "main" | "templates" | "vibe"; + +export function ProjectDashboard() { + const { + workflowId: currentWorkflowId, + hasUnsavedChanges, + setShowDashboard, + openProject, + deleteProject, + loadWorkflow, + setShowQuickstart, + } = useWorkflowStore(); + + const [view, setView] = useState("main"); + const [refreshKey, setRefreshKey] = useState(0); + const fileInputRef = useRef(null); + + const projects = useMemo( + () => getAllProjectsForDashboard(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [refreshKey] + ); + + const confirmUnsavedChanges = useCallback((): boolean => { + if (!hasUnsavedChanges) return true; + return window.confirm( + "You have unsaved changes. Are you sure you want to switch projects? Your unsaved changes will be lost." + ); + }, [hasUnsavedChanges]); + + const handleOpenProject = useCallback( + async (wfId: string) => { + // If it's the already-loaded project, just close dashboard + if (wfId === currentWorkflowId) { + setShowDashboard(false); + return; + } + if (!confirmUnsavedChanges()) return; + await openProject(wfId); + }, + [currentWorkflowId, confirmUnsavedChanges, openProject, setShowDashboard] + ); + + const handleDeleteProject = useCallback( + (wfId: string) => { + deleteProject(wfId); + setRefreshKey((k) => k + 1); + }, + [deleteProject] + ); + + const handleNewProject = useCallback(() => { + if (!confirmUnsavedChanges()) return; + setShowDashboard(false); + setShowQuickstart(false); + // Open the new project setup modal — this is triggered via WorkflowCanvas + // by closing the dashboard with no project loaded + useWorkflowStore.getState().clearWorkflow(); + // Dispatch a custom event that WorkflowCanvas listens for + window.dispatchEvent(new CustomEvent("dashboard:new-project")); + }, [confirmUnsavedChanges, setShowDashboard, setShowQuickstart]); + + const handleLoadFile = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!confirmUnsavedChanges()) { + e.target.value = ""; + return; + } + + const reader = new FileReader(); + reader.onload = async (event) => { + try { + const workflow = JSON.parse(event.target?.result as string) as WorkflowFile; + if (workflow.version && workflow.nodes && workflow.edges) { + await loadWorkflow(workflow); + setShowDashboard(false); + } else { + alert("Invalid workflow file format"); + } + } catch { + alert("Failed to parse workflow file"); + } + }; + reader.readAsText(file); + e.target.value = ""; + }, + [confirmUnsavedChanges, loadWorkflow, setShowDashboard] + ); + + const handleWorkflowGenerated = useCallback( + async (workflow: WorkflowFile) => { + await loadWorkflow(workflow); + setShowDashboard(false); + }, + [loadWorkflow, setShowDashboard] + ); + + const dialogWidth = view === "templates" ? "max-w-6xl" : "max-w-3xl"; + const dialogHeight = view === "templates" ? "max-h-[85vh]" : "max-h-[80vh]"; + + return ( +
e.stopPropagation()} + > +
+ {view === "templates" && ( + setView("main")} + onWorkflowSelected={handleWorkflowGenerated} + /> + )} + {view === "vibe" && ( + setView("main")} + onWorkflowGenerated={handleWorkflowGenerated} + /> + )} + {view === "main" && ( +
+ {/* Left column — Info */} +
+
+
+ +

+ Node Banana +

+
+
+

+ A node based workflow editor for AI image generation. +

+ + +
+ + {/* Right column — Projects + actions */} +
+ {/* Action buttons row */} +
+ + + + + + {/* Close button (when there's a current project to return to) */} + {currentWorkflowId && ( + + )} +
+ + {/* Projects list */} +
+ {projects.length === 0 ? ( +
+ + + +

No projects yet

+

+ Create a new project or load an existing workflow file to get started. +

+
+ ) : ( +
+

+ {projects.length} project{projects.length !== 1 ? "s" : ""} +

+ {projects.map((project) => ( + + ))} +
+ )} +
+
+
+ )} +
+ + {/* Hidden file input for loading workflows */} + +
+ ); +} diff --git a/src/store/utils/localStorage.ts b/src/store/utils/localStorage.ts index eb9500eb..231fbd21 100644 --- a/src/store/utils/localStorage.ts +++ b/src/store/utils/localStorage.ts @@ -60,10 +60,68 @@ export const loadSaveConfigs = (): Record => { export const saveSaveConfig = (config: WorkflowSaveConfig): void => { if (typeof window === "undefined") return; const configs = loadSaveConfigs(); - configs[config.workflowId] = config; + const existing = configs[config.workflowId]; + const now = Date.now(); + configs[config.workflowId] = { + ...config, + createdAt: config.createdAt ?? existing?.createdAt ?? now, + updatedAt: now, + }; localStorage.setItem(STORAGE_KEY, JSON.stringify(configs)); }; +export const deleteSaveConfig = (workflowId: string): void => { + if (typeof window === "undefined") return; + const configs = loadSaveConfigs(); + delete configs[workflowId]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(configs)); + // Clean up cost data + const costStored = localStorage.getItem(COST_DATA_STORAGE_KEY); + if (costStored) { + try { + const allCosts: Record = JSON.parse(costStored); + delete allCosts[workflowId]; + localStorage.setItem(COST_DATA_STORAGE_KEY, JSON.stringify(allCosts)); + } catch { /* ignore */ } + } +}; + +export interface DashboardProject { + workflowId: string; + name: string; + directoryPath: string; + lastSavedAt: number | null; + createdAt?: number; + updatedAt?: number; + nodeCount?: number; + edgeCount?: number; + incurredCost: number; +} + +export const getAllProjectsForDashboard = (): DashboardProject[] => { + if (typeof window === "undefined") return []; + const configs = loadSaveConfigs(); + let allCosts: Record = {}; + const costStored = localStorage.getItem(COST_DATA_STORAGE_KEY); + if (costStored) { + try { allCosts = JSON.parse(costStored); } catch { /* ignore */ } + } + + return Object.values(configs) + .map((config) => ({ + workflowId: config.workflowId, + name: config.name, + directoryPath: config.directoryPath, + lastSavedAt: config.lastSavedAt, + createdAt: config.createdAt, + updatedAt: config.updatedAt, + nodeCount: config.nodeCount, + edgeCount: config.edgeCount, + incurredCost: allCosts[config.workflowId]?.incurredCost ?? 0, + })) + .sort((a, b) => (b.updatedAt ?? b.lastSavedAt ?? 0) - (a.updatedAt ?? a.lastSavedAt ?? 0)); +}; + // Cost data helpers export const loadWorkflowCostData = (workflowId: string): WorkflowCostData | null => { if (typeof window === "undefined") return null; diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 8df3f97e..fed24b51 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -31,6 +31,7 @@ import { EditOperation, applyEditOperations as executeEditOps } from "@/lib/chat import { loadSaveConfigs, saveSaveConfig, + deleteSaveConfig, loadWorkflowCostData, saveWorkflowCostData, getProviderSettings, @@ -209,9 +210,15 @@ interface WorkflowStore { openModalCount: number; isModalOpen: boolean; showQuickstart: boolean; + showDashboard: boolean; incrementModalCount: () => void; decrementModalCount: () => void; setShowQuickstart: (show: boolean) => void; + setShowDashboard: (show: boolean) => void; + + // Dashboard actions + openProject: (workflowId: string) => Promise; + deleteProject: (workflowId: string) => void; // Execution isRunning: boolean; @@ -378,6 +385,7 @@ export const useWorkflowStore = create((set, get) => ({ openModalCount: 0, isModalOpen: false, showQuickstart: true, + showDashboard: true, isRunning: false, currentNodeIds: [], // Changed from currentNodeId for parallel execution pausedAtNodeId: null, @@ -447,6 +455,47 @@ export const useWorkflowStore = create((set, get) => ({ set({ showQuickstart: show }); }, + setShowDashboard: (show: boolean) => { + set({ showDashboard: show }); + }, + + openProject: async (workflowId: string) => { + const configs = loadSaveConfigs(); + const config = configs[workflowId]; + if (!config) return; + + try { + const response = await fetch("/api/workflow-load", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + directoryPath: config.directoryPath, + filename: config.name, + }), + }); + const result = await response.json(); + if (result.success && result.workflow) { + await get().loadWorkflow(result.workflow, config.directoryPath); + set({ showDashboard: false, showQuickstart: false }); + } else { + useToast.getState().show(`Failed to load project: ${result.error || "Unknown error"}`, "error"); + } + } catch (error) { + useToast.getState().show( + `Failed to load project: ${error instanceof Error ? error.message : "Unknown error"}`, + "error" + ); + } + }, + + deleteProject: (workflowId: string) => { + deleteSaveConfig(workflowId); + // If the deleted project is currently loaded, clear it + if (get().workflowId === workflowId) { + get().clearWorkflow(); + } + }, + addNode: (type: NodeType, position: XYPosition, initialData?: Partial) => { const id = `${type}-${++nodeIdCounter}`; @@ -1814,6 +1863,8 @@ export const useWorkflowStore = create((set, get) => ({ generationsPath: get().generationsPath, lastSavedAt: timestamp, useExternalImageStorage, + nodeCount: get().nodes.length, + edgeCount: get().edges.length, }); return true; diff --git a/src/types/workflow.ts b/src/types/workflow.ts index 4d4d1b5c..bc63546e 100644 --- a/src/types/workflow.ts +++ b/src/types/workflow.ts @@ -24,6 +24,10 @@ export interface WorkflowSaveConfig { generationsPath: string | null; lastSavedAt: number | null; useExternalImageStorage?: boolean; // Whether to store images as files vs embedded base64 + createdAt?: number; // Set on first save + updatedAt?: number; // Set on every save + nodeCount?: number; // Snapshot at save time + edgeCount?: number; // Snapshot at save time } // Cost tracking data stored per-workflow in localStorage From 1409ac8de327a15aeb11b1752c9e3c0afa640ab2 Mon Sep 17 00:00:00 2001 From: MrZang101 <51847523+MrZang101@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:42:45 +0000 Subject: [PATCH 2/3] feat: enhance dashboard UI with richer project cards and bigger layout - Widen dashboard to max-w-5xl with more spacious padding and typography - Add nodeTypeSummary and primaryModel to WorkflowSaveConfig, computed at save time - Show node type pills (Generate, Video, LLM, etc.) with icons on project cards - Derive workflow type badge (Image Gen, Video, 3D, Audio, LLM) from node composition - Show primary model, edge count, and cost in card meta row - Add project stats panel (total projects/nodes) in left sidebar - Highlight New Project button with blue accent color - Add "Back to project" close button with return arrow icon - Bigger empty state with icon box and helpful text Co-Authored-By: Claude Opus 4.6 --- src/components/dashboard/ProjectCard.tsx | 193 ++++++++++++++++-- src/components/dashboard/ProjectDashboard.tsx | 145 +++++++------ src/store/utils/localStorage.ts | 4 + src/store/workflowStore.ts | 21 +- src/types/workflow.ts | 2 + 5 files changed, 280 insertions(+), 85 deletions(-) diff --git a/src/components/dashboard/ProjectCard.tsx b/src/components/dashboard/ProjectCard.tsx index 6a175b31..95fd88b3 100644 --- a/src/components/dashboard/ProjectCard.tsx +++ b/src/components/dashboard/ProjectCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo } from "react"; import type { DashboardProject } from "@/store/utils/localStorage"; function formatRelativeTime(timestamp: number | undefined | null): string { @@ -21,13 +21,101 @@ function formatCost(cost: number): string { return `$${cost.toFixed(2)}`; } -function truncatePath(dirPath: string, maxLen: number = 40): string { +function truncatePath(dirPath: string, maxLen: number = 50): string { if (dirPath.length <= maxLen) return dirPath; const parts = dirPath.split("/"); if (parts.length <= 2) return "..." + dirPath.slice(-maxLen); return ".../" + parts.slice(-2).join("/"); } +// Derive a "workflow type" label from the node composition +function deriveWorkflowType(summary?: Record): { label: string; color: string } | null { + if (!summary) return null; + const has = (k: string) => (summary[k] ?? 0) > 0; + + if (has("generateVideo")) return { label: "Video", color: "text-purple-400 bg-purple-500/15 border-purple-500/20" }; + if (has("generate3d")) return { label: "3D", color: "text-orange-400 bg-orange-500/15 border-orange-500/20" }; + if (has("generateAudio")) return { label: "Audio", color: "text-green-400 bg-green-500/15 border-green-500/20" }; + if (has("nanoBanana")) return { label: "Image Gen", color: "text-yellow-400 bg-yellow-500/15 border-yellow-500/20" }; + if (has("llmGenerate")) return { label: "LLM", color: "text-blue-400 bg-blue-500/15 border-blue-500/20" }; + if (has("annotation")) return { label: "Annotation", color: "text-cyan-400 bg-cyan-500/15 border-cyan-500/20" }; + return null; +} + +// Small node-type icon pills +const NODE_TYPE_ICONS: Record = { + nanoBanana: { + label: "Generate", + icon: ( + + + + ), + }, + generateVideo: { + label: "Video", + icon: ( + + + + ), + }, + llmGenerate: { + label: "LLM", + icon: ( + + + + ), + }, + prompt: { + label: "Prompts", + icon: ( + + + + ), + }, + imageInput: { + label: "Images", + icon: ( + + + + ), + }, + annotation: { + label: "Draw", + icon: ( + + + + ), + }, + generateAudio: { + label: "Audio", + icon: ( + + + + ), + }, + generate3d: { + label: "3D", + icon: ( + + + + ), + }, +}; + +// Ordered by visual priority +const NODE_TYPE_DISPLAY_ORDER = [ + "nanoBanana", "generateVideo", "generate3d", "generateAudio", + "llmGenerate", "annotation", "prompt", "imageInput", +]; + interface ProjectCardProps { project: DashboardProject; isCurrent: boolean; @@ -53,45 +141,66 @@ export function ProjectCard({ project, isCurrent, onOpen, onDelete }: ProjectCar setShowDeleteConfirm(false); }, []); + const workflowType = useMemo( + () => deriveWorkflowType(project.nodeTypeSummary), + [project.nodeTypeSummary] + ); + + // Get node type pills to show (top 4 by display order) + const nodeTypePills = useMemo(() => { + if (!project.nodeTypeSummary) return []; + return NODE_TYPE_DISPLAY_ORDER + .filter((t) => (project.nodeTypeSummary![t] ?? 0) > 0) + .slice(0, 4) + .map((t) => ({ + type: t, + count: project.nodeTypeSummary![t], + ...NODE_TYPE_ICONS[t], + })); + }, [project.nodeTypeSummary]); + return ( @@ -99,10 +208,10 @@ export function ProjectCard({ project, isCurrent, onOpen, onDelete }: ProjectCar ) : ( @@ -110,19 +219,61 @@ export function ProjectCard({ project, isCurrent, onOpen, onDelete }: ProjectCar - {/* Meta row */} -
- {formatRelativeTime(project.updatedAt ?? project.lastSavedAt)} + {/* Path row */} +

+ {truncatePath(project.directoryPath)} +

+ + {/* Node type pills */} + {nodeTypePills.length > 0 && ( +
+ {nodeTypePills.map((pill) => ( + + {pill.icon} + {pill.count > 1 ? `${pill.label} x${pill.count}` : pill.label} + + ))} +
+ )} + + {/* Bottom meta row */} +
+ {/* Time */} + + + + + {formatRelativeTime(project.updatedAt ?? project.lastSavedAt)} + + {(project.nodeCount != null && project.nodeCount > 0) && ( <> · {project.nodeCount} nodes )} + + {(project.edgeCount != null && project.edgeCount > 0) && ( + <> + · + {project.edgeCount} connections + + )} + + {project.primaryModel && ( + <> + · + {project.primaryModel} + + )} + {project.incurredCost > 0 && ( <> · - {formatCost(project.incurredCost)} + {formatCost(project.incurredCost)} )}
diff --git a/src/components/dashboard/ProjectDashboard.tsx b/src/components/dashboard/ProjectDashboard.tsx index d8aa497d..f68e808e 100644 --- a/src/components/dashboard/ProjectDashboard.tsx +++ b/src/components/dashboard/ProjectDashboard.tsx @@ -39,7 +39,6 @@ export function ProjectDashboard() { const handleOpenProject = useCallback( async (wfId: string) => { - // If it's the already-loaded project, just close dashboard if (wfId === currentWorkflowId) { setShowDashboard(false); return; @@ -62,10 +61,7 @@ export function ProjectDashboard() { if (!confirmUnsavedChanges()) return; setShowDashboard(false); setShowQuickstart(false); - // Open the new project setup modal — this is triggered via WorkflowCanvas - // by closing the dashboard with no project loaded useWorkflowStore.getState().clearWorkflow(); - // Dispatch a custom event that WorkflowCanvas listens for window.dispatchEvent(new CustomEvent("dashboard:new-project")); }, [confirmUnsavedChanges, setShowDashboard, setShowQuickstart]); @@ -110,16 +106,17 @@ export function ProjectDashboard() { [loadWorkflow, setShowDashboard] ); - const dialogWidth = view === "templates" ? "max-w-6xl" : "max-w-3xl"; - const dialogHeight = view === "templates" ? "max-h-[85vh]" : "max-h-[80vh]"; + const dialogWidth = view === "templates" ? "max-w-6xl" : "max-w-5xl"; + const dialogHeight = view === "templates" ? "max-h-[85vh]" : "max-h-[85vh]"; return (
e.stopPropagation()} >
{view === "templates" && ( )} {view === "main" && ( -
- {/* Left column — Info */} -
-
-
- -

+
+ {/* Left column — Branding + links */} +
+
+
+ +

Node Banana

-

- A node based workflow editor for AI image generation. +

+ A node based workflow editor for AI image generation. Connect nodes to build pipelines.

-
+ {/* Quick stats */} + {projects.length > 0 && ( +
+
+
+

{projects.length}

+

Projects

+
+
+

+ {projects.reduce((sum, p) => sum + (p.nodeCount ?? 0), 0)} +

+

Total Nodes

+
+
+
+ )} + +
{/* Right column — Projects + actions */} -
+
+ {/* Header with title + close */} +
+

Your Projects

+ {currentWorkflowId && ( + + )} +
+ {/* Action buttons row */} -
+
+
- - {/* Close button (when there's a current project to return to) */} - {currentWorkflowId && ( - - )}
{/* Projects list */} -
+
{projects.length === 0 ? ( -
- - - -

No projects yet

-

- Create a new project or load an existing workflow file to get started. +

+
+ + + +
+

No projects yet

+

+ Create a new project, load an existing workflow file, or start from a template to get going.

) : ( -
-

- {projects.length} project{projects.length !== 1 ? "s" : ""} -

+
{projects.map((project) => ( ; + primaryModel?: string; incurredCost: number; } @@ -117,6 +119,8 @@ export const getAllProjectsForDashboard = (): DashboardProject[] => { updatedAt: config.updatedAt, nodeCount: config.nodeCount, edgeCount: config.edgeCount, + nodeTypeSummary: config.nodeTypeSummary, + primaryModel: config.primaryModel, incurredCost: allCosts[config.workflowId]?.incurredCost ?? 0, })) .sort((a, b) => (b.updatedAt ?? b.lastSavedAt ?? 0) - (a.updatedAt ?? a.lastSavedAt ?? 0)); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index fed24b51..bf0ddf67 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1855,6 +1855,23 @@ export const useWorkflowStore = create((set, get) => ({ }); } + // Compute summary metadata for dashboard + const currentNodesSnap = get().nodes; + const nodeTypeSummary: Record = {}; + let primaryModel: string | undefined; + const modelCounts: Record = {}; + for (const node of currentNodesSnap) { + const t = node.type || "unknown"; + nodeTypeSummary[t] = (nodeTypeSummary[t] || 0) + 1; + const data = node.data as Record; + const sel = data.selectedModel as { displayName?: string } | undefined; + if (sel?.displayName) { + modelCounts[sel.displayName] = (modelCounts[sel.displayName] || 0) + 1; + } + } + const topModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]; + if (topModel) primaryModel = topModel[0]; + // Update localStorage saveSaveConfig({ workflowId, @@ -1863,8 +1880,10 @@ export const useWorkflowStore = create((set, get) => ({ generationsPath: get().generationsPath, lastSavedAt: timestamp, useExternalImageStorage, - nodeCount: get().nodes.length, + nodeCount: currentNodesSnap.length, edgeCount: get().edges.length, + nodeTypeSummary, + primaryModel, }); return true; diff --git a/src/types/workflow.ts b/src/types/workflow.ts index bc63546e..49ea432c 100644 --- a/src/types/workflow.ts +++ b/src/types/workflow.ts @@ -28,6 +28,8 @@ export interface WorkflowSaveConfig { updatedAt?: number; // Set on every save nodeCount?: number; // Snapshot at save time edgeCount?: number; // Snapshot at save time + nodeTypeSummary?: Record; // e.g. { nanoBanana: 2, prompt: 3 } + primaryModel?: string; // Display name of most-used generation model } // Cost tracking data stored per-workflow in localStorage From 4356a18c6911b10f495f8227d965458a56b60684 Mon Sep 17 00:00:00 2001 From: MrZang101 <51847523+MrZang101@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:45:41 +0000 Subject: [PATCH 3/3] feat: add autosave indicator and floating Projects button on canvas - Show green dot next to save time when auto-save is enabled - Show yellow pulsing dot while actively saving - Add floating "Projects" button at bottom-left of canvas for quick access to dashboard without going to the header Co-Authored-By: Claude Opus 4.6 --- src/components/Header.tsx | 13 ++++++++++--- src/components/WorkflowCanvas.tsx | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a88ff5b0..e71f0f91 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -73,6 +73,7 @@ export function Header() { shortcutsDialogOpen, setShortcutsDialogOpen, setShowDashboard, + autoSaveEnabled, } = useWorkflowStore(); const [showProjectModal, setShowProjectModal] = useState(false); @@ -373,12 +374,18 @@ export function Header() { )} - + {isProjectConfigured ? ( isSaving ? ( - "Saving..." + <> + + Saving... + ) : lastSavedAt ? ( - `Saved ${formatTime(lastSavedAt)}` + <> + {autoSaveEnabled && } + Saved {formatTime(lastSavedAt)} + ) : ( "Not saved" ) diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 4f5a65dc..d66fa3ea 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -1770,6 +1770,20 @@ export function WorkflowCanvas() { {/* Global image history */} + {/* Floating projects button */} + {!showDashboard && ( + + )} + {/* Chat toggle button - hidden for now */} {/* Chat panel */}