Browse Source

feat(40-01): build OutputGalleryNode component with thumbnail grid and lightbox

- Create OutputGalleryNode.tsx with BaseNode wrapper
- Single image input handle (type target, id image)
- Real-time image collection from connected nodes (imageInput, annotation, nanoBanana)
- 3-column scrollable thumbnail grid with nowheel for scroll isolation
- Full-screen lightbox via createPortal with:
  - Left/right arrow navigation
  - Download button (saves as gallery-image-N.png)
  - Close button (X icon, top-right)
  - Click backdrop to close
  - Keyboard support (Escape, ArrowLeft, ArrowRight)
  - Image counter display (N / Total)
- Empty state placeholder when no images connected
- Fix validation.ts: add outputGallery dimensions and createDefaultNodeData case
- Build passes with zero errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
075471ed5b
  1. 228
      src/components/nodes/OutputGalleryNode.tsx
  2. 5
      src/lib/quickstart/validation.ts

228
src/components/nodes/OutputGalleryNode.tsx

@ -0,0 +1,228 @@
"use client";
import { useState, useCallback, useMemo, useEffect } from "react";
import { createPortal } from "react-dom";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { useWorkflowStore } from "@/store/workflowStore";
import { OutputGalleryNodeData } from "@/types";
type OutputGalleryNodeType = Node<OutputGalleryNodeData, "outputGallery">;
export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGalleryNodeType>) {
const nodeData = data;
const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
// Collect images in real-time from connected nodes (not just during execution)
const displayImages = useMemo(() => {
// Start with images from execution (data.images)
const executionImages = [...(nodeData.images || [])];
// Also collect images from currently connected nodes
const connectedImages: string[] = [];
edges
.filter((edge) => edge.target === id)
.forEach((edge) => {
const sourceNode = nodes.find((n) => n.id === edge.source);
if (!sourceNode) return;
let image: string | null = null;
// Extract image from different node types
if (sourceNode.type === "imageInput") {
image = (sourceNode.data as any).image;
} else if (sourceNode.type === "annotation") {
image = (sourceNode.data as any).outputImage;
} else if (sourceNode.type === "nanoBanana") {
image = (sourceNode.data as any).outputImage;
}
if (image) {
connectedImages.push(image);
}
});
// Combine both sources, removing duplicates
const allImages = [...new Set([...executionImages, ...connectedImages])];
return allImages;
}, [nodeData.images, edges, nodes, id]);
const openLightbox = useCallback((index: number) => {
setLightboxIndex(index);
}, []);
const closeLightbox = useCallback(() => {
setLightboxIndex(null);
}, []);
const navigateLightbox = useCallback(
(direction: "prev" | "next") => {
if (lightboxIndex === null) return;
if (direction === "prev" && lightboxIndex > 0) {
setLightboxIndex(lightboxIndex - 1);
} else if (direction === "next" && lightboxIndex < displayImages.length - 1) {
setLightboxIndex(lightboxIndex + 1);
}
},
[lightboxIndex, displayImages.length]
);
const downloadImage = useCallback(() => {
if (lightboxIndex === null) return;
const image = displayImages[lightboxIndex];
if (!image) return;
const link = document.createElement("a");
link.href = image;
link.download = `gallery-image-${lightboxIndex + 1}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [lightboxIndex, displayImages]);
// Keyboard navigation for lightbox
useEffect(() => {
if (lightboxIndex === null) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "Escape":
closeLightbox();
break;
case "ArrowLeft":
navigateLightbox("prev");
break;
case "ArrowRight":
navigateLightbox("next");
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [lightboxIndex, closeLightbox, navigateLightbox]);
return (
<>
<BaseNode
id={id}
title="Output Gallery"
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
selected={selected}
className="min-w-[200px]"
commentNavigation={commentNavigation ?? undefined}
>
<Handle
type="target"
position={Position.Left}
id="image"
data-handletype="image"
/>
{displayImages.length === 0 ? (
<div className="w-full flex-1 min-h-[200px] border border-dashed border-neutral-600 rounded flex items-center justify-center">
<span className="text-neutral-500 text-[10px] text-center px-4">
Connect image nodes to view gallery
</span>
</div>
) : (
<div className="flex-1 overflow-y-auto nodrag nopan nowheel">
<div className="grid grid-cols-3 gap-1.5 p-1">
{displayImages.map((img, idx) => (
<button
key={idx}
onClick={() => openLightbox(idx)}
className="aspect-square rounded border border-neutral-700 hover:border-neutral-500 overflow-hidden transition-colors"
>
<img
src={img}
alt={`Image ${idx + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
</div>
)}
</BaseNode>
{/* Lightbox Portal */}
{lightboxIndex !== null && typeof document !== "undefined" &&
createPortal(
<div
className="fixed inset-0 bg-black/90 z-[100] flex items-center justify-center p-8"
onClick={closeLightbox}
>
<div className="relative max-w-full max-h-full" onClick={(e) => e.stopPropagation()}>
<img
src={displayImages[lightboxIndex]}
alt={`Gallery image ${lightboxIndex + 1}`}
className="max-w-full max-h-[90vh] object-contain rounded"
/>
{/* Close button */}
<button
onClick={closeLightbox}
className="absolute top-4 right-4 w-8 h-8 bg-white/10 hover:bg-white/20 rounded text-white text-sm transition-colors flex items-center justify-center"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Download button */}
<button
onClick={downloadImage}
className="absolute top-4 left-4 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded text-white text-xs font-medium transition-colors flex items-center gap-1.5"
>
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download
</button>
{/* Left arrow */}
{lightboxIndex > 0 && (
<button
onClick={() => navigateLightbox("prev")}
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors flex items-center justify-center"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* Right arrow */}
{lightboxIndex < displayImages.length - 1 && (
<button
onClick={() => navigateLightbox("next")}
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors flex items-center justify-center"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{/* Image counter */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1.5 bg-black/50 rounded text-white text-xs font-medium">
{lightboxIndex + 1} / {displayImages.length}
</div>
</div>
</div>,
document.body
)}
</>
);
}

5
src/lib/quickstart/validation.ts

@ -34,6 +34,7 @@ const DEFAULT_DIMENSIONS: Record<NodeType, { width: number; height: number }> =
llmGenerate: { width: 320, height: 360 },
splitGrid: { width: 300, height: 320 },
output: { width: 320, height: 320 },
outputGallery: { width: 320, height: 360 },
};
/**
@ -264,6 +265,10 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData {
return {
image: null,
};
case "outputGallery":
return {
images: [],
};
}
}

Loading…
Cancel
Save