You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

447 lines
19 KiB

"use client";
import { useState, useRef, useMemo, useCallback } from "react";
import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore";
import { useShallow } from "zustand/shallow";
import { ProjectSetupModal } from "./ProjectSetupModal";
import { CostIndicator } from "./CostIndicator";
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
function CommentsNavigationIcon() {
// Subscribe to nodes so we re-render when comments change
const nodes = useWorkflowStore((state) => state.nodes);
const getNodesWithComments = useWorkflowStore((state) => state.getNodesWithComments);
const viewedCommentNodeIds = useWorkflowStore((state) => state.viewedCommentNodeIds);
const markCommentViewed = useWorkflowStore((state) => state.markCommentViewed);
const setNavigationTarget = useWorkflowStore((state) => state.setNavigationTarget);
// Recalculate when nodes change (nodes in dependency triggers re-render)
const nodesWithComments = useMemo(() => getNodesWithComments(), [getNodesWithComments, nodes]);
const unviewedCount = useMemo(() => {
return nodesWithComments.filter((node) => !viewedCommentNodeIds.has(node.id)).length;
}, [nodesWithComments, viewedCommentNodeIds]);
const totalCount = nodesWithComments.length;
const handleClick = useCallback(() => {
if (totalCount === 0) return;
// Find first unviewed comment, or first comment if all viewed
const targetNode = nodesWithComments.find((node) => !viewedCommentNodeIds.has(node.id)) || nodesWithComments[0];
if (targetNode) {
markCommentViewed(targetNode.id);
setNavigationTarget(targetNode.id);
}
}, [totalCount, nodesWithComments, viewedCommentNodeIds, markCommentViewed, setNavigationTarget]);
// Don't render if no comments
if (totalCount === 0) {
return null;
}
const displayCount = unviewedCount > 9 ? "9+" : unviewedCount.toString();
return (
<button
onClick={handleClick}
className="relative p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors"
title={`${unviewedCount} unviewed comment${unviewedCount !== 1 ? 's' : ''} (${totalCount} total)`}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M4.848 2.771A49.144 49.144 0 0112 2.25c2.43 0 4.817.178 7.152.52 1.978.292 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.678-3.348 3.97a48.901 48.901 0 01-3.476.383.39.39 0 00-.297.17l-2.755 4.133a.75.75 0 01-1.248 0l-2.755-4.133a.39.39 0 00-.297-.17 48.9 48.9 0 01-3.476-.384c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97z" clipRule="evenodd" />
</svg>
{unviewedCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[14px] h-[14px] flex items-center justify-center text-[9px] font-bold text-white bg-blue-500 rounded-full px-0.5">
{displayCount}
</span>
)}
</button>
);
}
export function Header() {
const {
workflowName,
workflowId,
saveDirectoryPath,
hasUnsavedChanges,
lastSavedAt,
isSaving,
setWorkflowMetadata,
saveToFile,
loadWorkflow,
previousWorkflowSnapshot,
revertToSnapshot,
shortcutsDialogOpen,
setShortcutsDialogOpen,
setShowQuickstart,
} = useWorkflowStore(useShallow((state) => ({
workflowName: state.workflowName,
workflowId: state.workflowId,
saveDirectoryPath: state.saveDirectoryPath,
hasUnsavedChanges: state.hasUnsavedChanges,
lastSavedAt: state.lastSavedAt,
isSaving: state.isSaving,
setWorkflowMetadata: state.setWorkflowMetadata,
saveToFile: state.saveToFile,
loadWorkflow: state.loadWorkflow,
previousWorkflowSnapshot: state.previousWorkflowSnapshot,
revertToSnapshot: state.revertToSnapshot,
shortcutsDialogOpen: state.shortcutsDialogOpen,
setShortcutsDialogOpen: state.setShortcutsDialogOpen,
setShowQuickstart: state.setShowQuickstart,
})));
const [showProjectModal, setShowProjectModal] = useState(false);
const [projectModalMode, setProjectModalMode] = useState<"new" | "settings">("new");
const fileInputRef = useRef<HTMLInputElement>(null);
const isProjectConfigured = !!workflowName;
const canSave = !!(workflowId && workflowName && saveDirectoryPath);
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
};
const handleNewProject = () => {
setProjectModalMode("new");
setShowProjectModal(true);
};
const handleOpenSettings = () => {
setProjectModalMode("settings");
setShowProjectModal(true);
};
const handleOpenFile = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) 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);
} else {
alert("Invalid workflow file format");
}
} catch {
alert("Failed to parse workflow file");
}
};
reader.readAsText(file);
// Reset input so same file can be loaded again
e.target.value = "";
};
const handleProjectSave = async (id: string, name: string, path: string) => {
setWorkflowMetadata(id, name, path); // generationsPath is auto-derived
setShowProjectModal(false);
// Small delay to let state update
setTimeout(() => {
saveToFile().catch((error) => {
console.error("Failed to save project:", error);
alert("Failed to save project. Please try again.");
});
}, 50);
};
const handleOpenDirectory = async () => {
if (!saveDirectoryPath) return;
try {
const response = await fetch("/api/open-directory", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ path: saveDirectoryPath }),
});
const result = await response.json();
if (!response.ok || !result.success) {
console.error("Failed to open directory:", result.error);
alert(`Failed to open project folder: ${result.error || "Unknown error"}`);
return;
}
} catch (error) {
console.error("Failed to open directory:", error);
alert("Failed to open project folder. Please try again.");
}
};
const handleRevertAIChanges = useCallback(() => {
const confirmed = window.confirm(
"Are you sure? This will restore your previous workflow."
);
if (confirmed) {
revertToSnapshot();
}
}, [revertToSnapshot]);
const settingsButtons = (
<div className="flex items-center gap-0.5 ml-1 pl-1 border-l border-neutral-700/50">
<button
onClick={handleOpenSettings}
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors"
title="Project settings"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
);
return (
<>
<ProjectSetupModal
isOpen={showProjectModal}
onClose={() => setShowProjectModal(false)}
onSave={handleProjectSave}
mode={projectModalMode}
/>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
className="hidden"
/>
<header className="h-11 bg-neutral-900 border-b border-neutral-800 flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2">
<button
onClick={() => setShowQuickstart(true)}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
title="Open welcome screen"
>
<img src="/banana_icon.png" alt="Banana" className="w-6 h-6" />
<h1 className="text-2xl font-semibold text-neutral-100 tracking-tight">
Node Banana
</h1>
</button>
<div className="flex items-center gap-2 ml-4 pl-4 border-l border-neutral-700">
{isProjectConfigured ? (
<>
<span className="text-sm text-neutral-300">{workflowName}</span>
<span className="text-neutral-600">|</span>
<CostIndicator />
{/* File operations group */}
<div className="flex items-center gap-0.5 ml-2 pl-2 border-l border-neutral-700/50">
<button
onClick={() => canSave ? saveToFile() : handleOpenSettings()}
disabled={isSaving}
className="relative p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors disabled:opacity-50"
title={isSaving ? "Saving..." : canSave ? "Save project" : "Configure save location"}
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
{hasUnsavedChanges && !isSaving && (
<span className="absolute top-0.5 right-0.5 w-2 h-2 rounded-full bg-red-500 ring-2 ring-neutral-900" />
)}
</button>
{saveDirectoryPath && (
<button
onClick={handleOpenDirectory}
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors"
title="Open Project Folder"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
</button>
)}
<button
onClick={handleOpenFile}
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors"
title="Open project"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>
</button>
</div>
{settingsButtons}
</>
) : (
<>
<span className="text-sm text-neutral-500 italic">Untitled</span>
{/* File operations group */}
<div className="flex items-center gap-0.5 ml-2 pl-2 border-l border-neutral-700/50">
<button
onClick={handleNewProject}
className="relative p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors"
title="Save project"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
<span className="absolute top-0.5 right-0.5 w-2 h-2 rounded-full bg-red-500 ring-2 ring-neutral-900" />
</button>
<button
onClick={handleOpenFile}
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors"
title="Open project"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>
</button>
</div>
{settingsButtons}
</>
)}
</div>
</div>
<div className="flex items-center gap-3 text-xs">
{previousWorkflowSnapshot && (
<button
onClick={handleRevertAIChanges}
className="px-2.5 py-1.5 text-xs text-neutral-300 hover:text-neutral-100 bg-neutral-700/50 hover:bg-neutral-700 border border-neutral-600 rounded transition-colors"
title="Restore workflow from before AI changes"
>
Revert AI Changes
</button>
)}
<CommentsNavigationIcon />
<span className="text-neutral-400">
{isProjectConfigured ? (
isSaving ? (
"Saving..."
) : lastSavedAt ? (
`Saved ${formatTime(lastSavedAt)}`
) : (
"Not saved"
)
) : (
"Not saved"
)}
</span>
<span className="text-neutral-500">·</span>
<a
href="https://x.com/ReflctWillie"
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 hover:text-neutral-200 transition-colors"
>
Made by Willie
</a>
<span className="text-neutral-500">·</span>
<button
onClick={() => setShortcutsDialogOpen(true)}
className="text-neutral-400 hover:text-neutral-200 transition-colors"
title="Keyboard shortcuts (?)"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75A2.25 2.25 0 014.5 4.5h15a2.25 2.25 0 012.25 2.25v10.5A2.25 2.25 0 0119.5 19.5h-15a2.25 2.25 0 01-2.25-2.25V6.75z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
</svg>
</button>
<span className="text-neutral-500">·</span>
<a
href="https://discord.com/invite/89Nr6EKkTf"
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 hover:text-neutral-200 transition-colors"
title="Support"
>
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</a>
</div>
</header>
<KeyboardShortcutsDialog
isOpen={shortcutsDialogOpen}
onClose={() => setShortcutsDialogOpen(false)}
/>
</>
);
}