From 16c444e3d00b71faeab6e5594c8e1781fb759cba Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 11 Mar 2026 14:03:02 +1300 Subject: [PATCH 1/4] fix(24-01): fix edge colors, Kie API gating, and media dimension detection - Add video, audio, text, 3d, easeCurve entries to EDGE_COLORS in SharedEdgeGradients - Normalize handle type suffixes (e.g. image-0 -> image) in EditableEdge color lookup - Gate Kie provider filter behind API key check (return 400 if missing) - Fix getMediaDimensions to check URL pathname for image extensions before defaulting to video Co-Authored-By: Claude Opus 4.6 --- src/app/api/models/route.ts | 12 ++++++- src/components/edges/EditableEdge.tsx | 33 ++++++++++++++------ src/components/edges/SharedEdgeGradients.tsx | 5 +++ src/utils/nodeDimensions.ts | 28 ++++++++++++----- 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 7b943a64..e5670103 100644 --- a/src/app/api/models/route.ts +++ b/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( + { + 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 diff --git a/src/components/edges/EditableEdge.tsx b/src/components/edges/EditableEdge.tsx index 32e57291..eee67525 100644 --- a/src/components/edges/EditableEdge.tsx +++ b/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 = { + 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]); diff --git a/src/components/edges/SharedEdgeGradients.tsx b/src/components/edges/SharedEdgeGradients.tsx index 174ff1e7..d41f09dd 100644 --- a/src/components/edges/SharedEdgeGradients.tsx +++ b/src/components/edges/SharedEdgeGradients.tsx @@ -10,6 +10,11 @@ const EDGE_COLORS: Record = { default: "#64748b", pause: "#ea580c", reference: "#52525b", + video: "#a855f7", + audio: "#f97316", + text: "#2563eb", + "3d": "#06b6d4", + easeCurve: "#f59e0b", }; const SELECTION_STATES = ["active", "dimmed"] as const; diff --git a/src/utils/nodeDimensions.ts b/src/utils/nodeDimensions.ts index 37ded0a0..65936e9f 100644 --- a/src/utils/nodeDimensions.ts +++ b/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); } From 621d7e06bafa8fb15c1f7aa3b3f63af23167d808 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 11 Mar 2026 14:05:40 +1300 Subject: [PATCH 2/4] fix(24-01): fix node UX issues - error display, accessibility, button states, dead code - LLM error view now shows nodeData.error message below "Generation failed" - ImageInputNode remove button has aria-label and keyboard focus styles - ImageInputNode empty state has role=button, tabIndex, keyboard handler - SplitGrid split button disabled when no source image connected - PromptNode dead modal code removed, variable dialog has clickable @var trigger - PromptConstructorNode dead modal code and unused imports removed Co-Authored-By: Claude Opus 4.6 --- src/components/nodes/ImageInputNode.tsx | 12 ++++- src/components/nodes/LLMGenerateNode.tsx | 3 ++ .../nodes/PromptConstructorNode.tsx | 34 -------------- src/components/nodes/PromptNode.tsx | 44 +++---------------- src/components/nodes/SplitGridNode.tsx | 4 +- 5 files changed, 23 insertions(+), 74 deletions(-) diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index 849fa190..c013f524 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -114,7 +114,8 @@ export function ImageInputNode({ id, data, selected }: NodeProps {/* Text output handle */} ) { /> - {/* Prompt Editor Modal - rendered via portal to escape React Flow stacking context */} - {isModalOpenLocal && createPortal( - , - document.body - )} - {/* Variable Naming Dialog - rendered via portal */} {showVarDialog && createPortal(
diff --git a/src/components/nodes/SplitGridNode.tsx b/src/components/nodes/SplitGridNode.tsx index 3f7070e3..a98355ce 100644 --- a/src/components/nodes/SplitGridNode.tsx +++ b/src/components/nodes/SplitGridNode.tsx @@ -148,9 +148,9 @@ export function SplitGridNode({ id, data, selected }: NodeProps Split From c7a5d2d542b7f9edf1d9f05801a940c314f141bd Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 11 Mar 2026 14:09:22 +1300 Subject: [PATCH 3/4] fix(24-01): fix presets popup dismiss and modal overlay click-through - Add presetsPopupRef to EaseCurve click-outside handler so clicking preset buttons no longer dismisses the popup - ProjectSetupModal overlay only closes when clicking the overlay itself (e.target === e.currentTarget), preventing portal child clicks from triggering close Co-Authored-By: Claude Opus 4.6 --- src/components/ProjectSetupModal.tsx | 5 +++-- src/components/nodes/ControlPanel.tsx | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index a8f6ba4d..2aa64262 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -383,12 +383,13 @@ export function ProjectSetupModal({ return (
{ + if (e.target === e.currentTarget) onClose(); + }} onWheelCapture={(e) => e.stopPropagation()} >
e.stopPropagation()} onKeyDown={handleKeyDown} >
diff --git a/src/components/nodes/ControlPanel.tsx b/src/components/nodes/ControlPanel.tsx index 888b155d..aa526284 100644 --- a/src/components/nodes/ControlPanel.tsx +++ b/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(null); + const presetsPopupRef = useRef(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(
Date: Wed, 11 Mar 2026 16:14:11 +1300 Subject: [PATCH 4/4] feat: inline variable highlights in PromptConstructor, update PromptNode label - Replace warning banner with inline highlights: blue for resolved @vars, red for unresolved - Add "N vars missing" notice in footer bar - Change PromptNode empty variable label from "@var" to "Add variable" Co-Authored-By: Claude Opus 4.6 --- .../nodes/PromptConstructorNode.tsx | 73 +++++++++++++++---- src/components/nodes/PromptNode.tsx | 2 +- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/components/nodes/PromptConstructorNode.tsx b/src/components/nodes/PromptConstructorNode.tsx index 0c11737c..9ef79bb7 100644 --- a/src/components/nodes/PromptConstructorNode.tsx +++ b/src/components/nodes/PromptConstructorNode.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState, useEffect, useMemo, useRef } from "react"; +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"; @@ -140,6 +140,40 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps(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( + {match[0]} + ); + lastIndex = idx + match[0].length; + } + if (lastIndex < localTemplate.length) { + parts.push(localTemplate.slice(lastIndex)); + } + return parts; + }, [localTemplate, availableVariables]); + const handleFocus = useCallback(() => { setIsEditing(true); }, []); @@ -169,15 +203,18 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps - {/* Warning badge for unresolved variables - overlay at top */} - {unresolvedVars.length > 0 && ( -
- Unresolved: {unresolvedVars.map(v => `@${v}`).join(', ')} -
- )} - - {/* Template textarea with autocomplete */} + {/* Template textarea with highlight overlay for @variables */}
+ {/* Highlight overlay - blue for resolved, red for unresolved @vars */} + {highlightedContent.length > 0 && ( +
0 || unresolvedVars.length > 0 ? "pb-7" : ""}`} + aria-hidden="true" + > + {highlightedContent} +
+ )}