Browse Source

Merge branch 'fix/code-review-findings-24' into develop

handoff-20260429-1057
shrimbly 4 months ago
parent
commit
b6b755e631
  1. 12
      src/app/api/models/route.ts
  2. 5
      src/components/ProjectSetupModal.tsx
  3. 33
      src/components/edges/EditableEdge.tsx
  4. 5
      src/components/edges/SharedEdgeGradients.tsx
  5. 3
      src/components/nodes/ControlPanel.tsx
  6. 12
      src/components/nodes/ImageInputNode.tsx
  7. 3
      src/components/nodes/LLMGenerateNode.tsx
  8. 107
      src/components/nodes/PromptConstructorNode.tsx
  9. 44
      src/components/nodes/PromptNode.tsx
  10. 4
      src/components/nodes/SplitGridNode.tsx
  11. 28
      src/utils/nodeDimensions.ts

12
src/app/api/models/route.ts

@ -972,7 +972,17 @@ export async function GET(
includeGemini = true;
} else if (providerFilter === "kie") {
// Only Kie requested - no external API calls needed (hardcoded models)
includeKie = true;
if (kieKey) {
includeKie = true;
} else {
return NextResponse.json<ModelsErrorResponse>(
{
success: false,
error: "Kie API key required. Add KIE_API_KEY to .env.local or configure in Settings.",
},
{ status: 400 }
);
}
} else if (providerFilter === "wavespeed") {
if (wavespeedKey) {
// WaveSpeed requested with key - fetch from API

5
src/components/ProjectSetupModal.tsx

@ -383,12 +383,13 @@ export function ProjectSetupModal({
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={onClose}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onWheelCapture={(e) => e.stopPropagation()}
>
<div
className="bg-neutral-800 rounded-xl w-[520px] border border-neutral-700 shadow-2xl overflow-clip flex flex-col max-h-[80vh]"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<div className="px-8 pt-8 pb-0 shrink-0">

33
src/components/edges/EditableEdge.tsx

@ -18,11 +18,17 @@ interface EdgeData extends WorkflowEdgeData {
}
// Colors for different connection types (dimmed for softer appearance)
const EDGE_COLORS = {
image: "#0d9668", // Dimmed green for image connections
prompt: "#2563eb", // Dimmed blue for prompt connections
default: "#64748b", // Dimmed gray for unknown
pause: "#ea580c", // Dimmed orange for paused edges
const EDGE_COLORS: Record<string, string> = {
image: "#0d9668", // Green for image connections
prompt: "#2563eb", // Blue for prompt connections
default: "#64748b", // Gray for unknown
pause: "#ea580c", // Orange for paused edges
reference: "#52525b", // Gray for reference connections
video: "#a855f7", // Purple for video connections
audio: "#f97316", // Orange for audio connections
text: "#2563eb", // Blue for text connections
"3d": "#06b6d4", // Cyan for 3D connections
easeCurve: "#f59e0b", // Amber for ease curve connections
};
export function EditableEdge({
@ -69,15 +75,22 @@ export function EditableEdge({
const edgeColor = useMemo(() => {
if (hasPause) return EDGE_COLORS.pause;
// Use source handle to determine color (or target if source is not available)
const handleType = sourceHandleId || targetHandleId;
if (handleType === "image") return EDGE_COLORS.image;
if (handleType === "prompt") return EDGE_COLORS.prompt;
return EDGE_COLORS.default;
// Strip numeric suffixes (e.g., "image-0" -> "image") for lookup
const handleType = sourceHandleId || targetHandleId || "";
const normalizedType = handleType.replace(/-\d+$/, "");
return EDGE_COLORS[normalizedType] || EDGE_COLORS.default;
}, [hasPause, sourceHandleId, targetHandleId]);
// Reference shared gradient by color key + selection state
const gradientId = useMemo(() => {
const colorKey = hasPause ? "pause" : (sourceHandleId || targetHandleId || "default");
if (hasPause) {
const selectionKey = isConnectedToSelection ? "active" : "dimmed";
return getSharedGradientId("pause", selectionKey);
}
const handleType = sourceHandleId || targetHandleId || "default";
const normalizedType = handleType.replace(/-\d+$/, "");
// Use the normalized type if it exists in EDGE_COLORS, otherwise fall back to "default"
const colorKey = normalizedType in EDGE_COLORS ? normalizedType : "default";
const selectionKey = isConnectedToSelection ? "active" : "dimmed";
return getSharedGradientId(colorKey, selectionKey);
}, [hasPause, sourceHandleId, targetHandleId, isConnectedToSelection]);

5
src/components/edges/SharedEdgeGradients.tsx

@ -10,6 +10,11 @@ const EDGE_COLORS: Record<string, string> = {
default: "#64748b",
pause: "#ea580c",
reference: "#52525b",
video: "#a855f7",
audio: "#f97316",
text: "#2563eb",
"3d": "#06b6d4",
easeCurve: "#f59e0b",
};
const SELECTION_STATES = ["active", "dimmed"] as const;

3
src/components/nodes/ControlPanel.tsx

@ -884,6 +884,7 @@ function EaseCurveControls({ node }: { node: Node }) {
const removeEdge = useWorkflowStore((state) => state.removeEdge);
const [showPresets, setShowPresets] = useState(false);
const presetsButtonRef = useRef<HTMLButtonElement>(null);
const presetsPopupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showPresets) return;
@ -893,6 +894,7 @@ function EaseCurveControls({ node }: { node: Node }) {
};
const handleClickOutside = (e: MouseEvent) => {
if (presetsButtonRef.current?.contains(e.target as HTMLElement)) return;
if (presetsPopupRef.current?.contains(e.target as HTMLElement)) return;
setShowPresets(false);
};
@ -1022,6 +1024,7 @@ function EaseCurveControls({ node }: { node: Node }) {
{showPresets && typeof document !== 'undefined' && createPortal(
<div
ref={presetsPopupRef}
className="fixed z-[100] bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-2 max-h-[60vh] overflow-y-auto nowheel"
style={{
top: presetsButtonRef.current?.getBoundingClientRect().bottom || 0,

12
src/components/nodes/ImageInputNode.tsx

@ -114,7 +114,8 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
/>
<button
onClick={handleRemove}
className="absolute top-2 right-2 w-6 h-6 bg-black/60 hover:bg-red-600/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center"
aria-label="Remove image"
className="absolute top-2 right-2 w-6 h-6 bg-black/60 hover:bg-red-600/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 focus:opacity-100 focus:ring-1 focus:ring-red-400 transition-all flex items-center justify-center"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@ -123,7 +124,16 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
</div>
) : (
<div
role="button"
tabIndex={0}
aria-label="Upload image"
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fileInputRef.current?.click();
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center cursor-pointer hover:bg-neutral-900/60 transition-colors"

3
src/components/nodes/LLMGenerateNode.tsx

@ -253,6 +253,9 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-white text-xs font-medium">Generation failed</span>
{nodeData.error && (
<span className="text-red-200 text-[10px] text-center px-3 mt-1 line-clamp-3">{nodeData.error}</span>
)}
</div>
) : nodeData.outputText ? (
<div className="group/text relative w-full h-full bg-neutral-900/40 p-2 overflow-auto nowheel">

107
src/components/nodes/PromptConstructorNode.tsx

@ -1,13 +1,11 @@
"use client";
import { useCallback, useState, useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { useCallback, useState, useEffect, useMemo, useRef, ReactNode } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { usePromptAutocomplete } from "@/hooks/usePromptAutocomplete";
import { useWorkflowStore } from "@/store/workflowStore";
import { PromptConstructorNodeData, PromptNodeData, LLMGenerateNodeData, AvailableVariable } from "@/types";
import { PromptConstructorEditorModal } from "@/components/modals/PromptConstructorEditorModal";
import { parseVarTags } from "@/utils/parseVarTags";
type PromptConstructorNodeType = Node<PromptConstructorNodeData, "promptConstructor">;
@ -17,13 +15,10 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
const incrementModalCount = useWorkflowStore((state) => state.incrementModalCount);
const decrementModalCount = useWorkflowStore((state) => state.decrementModalCount);
// Local state for template to prevent cursor jumping
const [localTemplate, setLocalTemplate] = useState(nodeData.template);
const [isEditing, setIsEditing] = useState(false);
const [isModalOpenLocal, setIsModalOpenLocal] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -145,6 +140,40 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
}
}, [nodeData.template, availableVariables, id, updateNodeData, nodeData.outputText]);
const highlightRef = useRef<HTMLDivElement>(null);
// Sync highlight overlay scroll with textarea
const handleScroll = useCallback(() => {
if (textareaRef.current && highlightRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
// Build highlighted text with blue for resolved, red for unresolved variables
const highlightedContent = useMemo((): ReactNode[] => {
const availableNames = new Set(availableVariables.map(v => v.name));
const pattern = /@(\w+)/g;
const parts: ReactNode[] = [];
let lastIndex = 0;
const matches = localTemplate.matchAll(pattern);
for (const match of matches) {
const idx = match.index!;
if (idx > lastIndex) {
parts.push(localTemplate.slice(lastIndex, idx));
}
const isResolved = availableNames.has(match[1]);
parts.push(
<mark key={idx} className={`${isResolved ? "bg-blue-400/30" : "bg-red-400/50"} text-transparent rounded-sm px-0.5 -mx-0.5 py-0.5`}>{match[0]}</mark>
);
lastIndex = idx + match[0].length;
}
if (lastIndex < localTemplate.length) {
parts.push(localTemplate.slice(lastIndex));
}
return parts;
}, [localTemplate, availableVariables]);
const handleFocus = useCallback(() => {
setIsEditing(true);
}, []);
@ -158,23 +187,6 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
setTimeout(() => closeAutocomplete(), 200);
}, [id, localTemplate, nodeData.template, updateNodeData, closeAutocomplete]);
const handleOpenModal = useCallback(() => {
setIsModalOpenLocal(true);
incrementModalCount();
}, [incrementModalCount]);
const handleCloseModal = useCallback(() => {
setIsModalOpenLocal(false);
decrementModalCount();
}, [decrementModalCount]);
const handleSubmitModal = useCallback(
(template: string) => {
updateNodeData(id, { template });
},
[id, updateNodeData]
);
return (
<>
<BaseNode
@ -191,15 +203,18 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
style={{ zIndex: 10 }}
/>
{/* Warning badge for unresolved variables - overlay at top */}
{unresolvedVars.length > 0 && (
<div className="absolute top-2 left-2 right-2 z-20 px-2 py-1 bg-amber-900/80 backdrop-blur-sm border border-amber-700/50 rounded text-[10px] text-amber-400 pointer-events-none">
<span className="font-semibold">Unresolved:</span> {unresolvedVars.map(v => `@${v}`).join(', ')}
</div>
)}
{/* Template textarea with autocomplete */}
{/* Template textarea with highlight overlay for @variables */}
<div className="relative w-full h-full">
{/* Highlight overlay - blue for resolved, red for unresolved @vars */}
{highlightedContent.length > 0 && (
<div
ref={highlightRef}
className={`absolute inset-0 p-3 text-xs leading-relaxed text-transparent bg-neutral-800 rounded-lg overflow-hidden whitespace-pre-wrap break-words pointer-events-none ${availableVariables.length > 0 || unresolvedVars.length > 0 ? "pb-7" : ""}`}
aria-hidden="true"
>
{highlightedContent}
</div>
)}
<textarea
ref={textareaRef}
value={localTemplate}
@ -207,8 +222,9 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onScroll={handleScroll}
placeholder="Type @ to insert variables..."
className={`nodrag nopan nowheel w-full h-full p-3 text-xs leading-relaxed text-neutral-100 bg-neutral-800 rounded-lg resize-none focus:outline-none placeholder:text-neutral-500 ${availableVariables.length > 0 ? "pb-7" : ""}`}
className={`nodrag nopan nowheel relative w-full h-full p-3 text-xs leading-relaxed text-neutral-100 rounded-lg resize-none focus:outline-none placeholder:text-neutral-500 ${highlightedContent.length > 0 ? "bg-transparent" : "bg-neutral-800"} ${availableVariables.length > 0 || unresolvedVars.length > 0 ? "pb-7" : ""}`}
title={resolvedPreview ? `Preview: ${resolvedPreview}` : undefined}
/>
@ -244,10 +260,17 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
)}
</div>
{/* Available variables - fixed footer pinned at bottom */}
{availableVariables.length > 0 && (
<div className="absolute bottom-0 left-0 right-0 z-10 px-3 py-1.5 bg-neutral-900/80 backdrop-blur-sm rounded-b-lg text-[10px] text-neutral-500 pointer-events-none">
Available: {availableVariables.map(v => `@${v.name}`).join(', ')}
{/* Footer - available vars + unresolved warning */}
{(availableVariables.length > 0 || unresolvedVars.length > 0) && (
<div className="absolute bottom-0 left-0 right-0 z-10 px-3 py-1.5 bg-neutral-900/80 backdrop-blur-sm rounded-b-lg text-[10px] pointer-events-none flex items-center justify-between gap-2">
<span className="text-neutral-500 truncate">
{availableVariables.length > 0 ? `Available: ${availableVariables.map(v => `@${v.name}`).join(', ')}` : ''}
</span>
{unresolvedVars.length > 0 && (
<span className="text-red-400 whitespace-nowrap">
{unresolvedVars.length} {unresolvedVars.length === 1 ? 'var' : 'vars'} missing
</span>
)}
</div>
)}
@ -260,18 +283,6 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
style={{ zIndex: 10 }}
/>
</BaseNode>
{/* Prompt Constructor Editor Modal - rendered via portal to escape React Flow stacking context */}
{isModalOpenLocal && createPortal(
<PromptConstructorEditorModal
isOpen={isModalOpenLocal}
initialTemplate={nodeData.template}
availableVariables={availableVariables}
onSubmit={handleSubmitModal}
onClose={handleCloseModal}
/>,
document.body
)}
</>
);
}

44
src/components/nodes/PromptNode.tsx

@ -6,18 +6,14 @@ import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { PromptNodeData } from "@/types";
import { PromptEditorModal } from "@/components/modals/PromptEditorModal";
type PromptNodeType = Node<PromptNodeData, "prompt">;
export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const incrementModalCount = useWorkflowStore((state) => state.incrementModalCount);
const decrementModalCount = useWorkflowStore((state) => state.decrementModalCount);
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
const edges = useWorkflowStore((state) => state.edges);
const [isModalOpenLocal, setIsModalOpenLocal] = useState(false);
// Local state for prompt to prevent cursor jumping during typing
const [localPrompt, setLocalPrompt] = useState(nodeData.prompt);
@ -75,23 +71,6 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
}
}, [id, localPrompt, nodeData.prompt, updateNodeData]);
const handleOpenModal = useCallback(() => {
setIsModalOpenLocal(true);
incrementModalCount();
}, [incrementModalCount]);
const handleCloseModal = useCallback(() => {
setIsModalOpenLocal(false);
decrementModalCount();
}, [decrementModalCount]);
const handleSubmitModal = useCallback(
(prompt: string) => {
updateNodeData(id, { prompt });
},
[id, updateNodeData]
);
const handleSaveVariableName = useCallback(() => {
updateNodeData(id, { variableName: varNameInput || undefined });
setShowVarDialog(false);
@ -133,11 +112,13 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
placeholder={hasIncomingTextConnection ? "Text from connected node (editable)..." : "Describe what to generate..."}
className="nodrag nopan nowheel w-full h-full p-3 text-xs leading-relaxed text-neutral-100 bg-neutral-800 rounded-lg resize-none focus:outline-none placeholder:text-neutral-500"
/>
{nodeData.variableName && (
<div className="absolute bottom-2 left-3 z-10 text-[10px] text-blue-400 pointer-events-none">
@{nodeData.variableName}
</div>
)}
<button
onClick={() => setShowVarDialog(true)}
className="nodrag nopan absolute bottom-2 left-3 z-10 text-[10px] text-blue-400 hover:text-blue-300 transition-colors"
title="Set variable name"
>
{nodeData.variableName ? `@${nodeData.variableName}` : "Add variable"}
</button>
{/* Text output handle */}
<Handle
@ -149,17 +130,6 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
/>
</BaseNode>
{/* Prompt Editor Modal - rendered via portal to escape React Flow stacking context */}
{isModalOpenLocal && createPortal(
<PromptEditorModal
isOpen={isModalOpenLocal}
initialPrompt={nodeData.prompt}
onSubmit={handleSubmitModal}
onClose={handleCloseModal}
/>,
document.body
)}
{/* Variable Naming Dialog - rendered via portal */}
{showVarDialog && createPortal(
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[9999]">

4
src/components/nodes/SplitGridNode.tsx

@ -148,9 +148,9 @@ export function SplitGridNode({ id, data, selected }: NodeProps<SplitGridNodeTyp
{/* Split button */}
<button
onClick={handleSplit}
disabled={isRunning || !nodeData.isConfigured}
disabled={isRunning || !nodeData.isConfigured || !nodeData.sourceImage}
className="nodrag nopan px-2 py-0.5 text-[10px] border border-white hover:bg-white hover:text-neutral-900 disabled:border-neutral-600 disabled:text-neutral-600 disabled:cursor-not-allowed text-white rounded transition-colors"
title={!nodeData.isConfigured ? "Configure node first" : "Split grid"}
title={!nodeData.isConfigured ? "Configure node first" : !nodeData.sourceImage ? "Connect an image first" : "Split grid"}
>
Split
</button>

28
src/utils/nodeDimensions.ts

@ -11,7 +11,7 @@ export function getImageDimensions(
base64DataUrl: string
): Promise<{ width: number; height: number } | null> {
return new Promise((resolve) => {
if (!base64DataUrl || !base64DataUrl.startsWith("data:image")) {
if (!base64DataUrl || (!base64DataUrl.startsWith("data:image") && !base64DataUrl.startsWith("http"))) {
resolve(null);
return;
}
@ -103,12 +103,26 @@ export function getMediaDimensions(
return getImageDimensions(url);
}
// data:video/*, blob:*, or http(s) URLs → treat as video
if (
url.startsWith("data:video") ||
url.startsWith("blob:") ||
url.startsWith("http")
) {
// data:video/* → always video
if (url.startsWith("data:video")) {
return getVideoDimensions(url);
}
// blob:* → treat as video (most common use case)
if (url.startsWith("blob:")) {
return getVideoDimensions(url);
}
// http(s) URLs → check pathname for image extensions before defaulting to video
if (url.startsWith("http")) {
try {
const pathname = new URL(url).pathname.toLowerCase();
if (/\.(jpe?g|png|gif|webp|bmp|svg|avif|ico)(\?|$)/.test(pathname)) {
return getImageDimensions(url);
}
} catch {
// Invalid URL, fall through to video
}
return getVideoDimensions(url);
}

Loading…
Cancel
Save