diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 7e76c2fa..8cd25ce1 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -4,6 +4,7 @@ import './globals.css'; import Sidebar from '@/components/Sidebar'; import { ThemeProvider } from '@/components/ThemeProvider'; import ConfirmModal from '@/components/ConfirmModal'; +import SampleImageModal from '@/components/SampleImageModal'; const inter = Inter({ subsets: ['latin'] }); @@ -23,6 +24,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + ); diff --git a/ui/src/components/SampleImageCard.tsx b/ui/src/components/SampleImageCard.tsx index 5a4fa91b..857d3ffa 100644 --- a/ui/src/components/SampleImageCard.tsx +++ b/ui/src/components/SampleImageCard.tsx @@ -1,14 +1,17 @@ import React, { useRef, useEffect, useState, ReactNode } from 'react'; +import { sampleImageModalState } from '@/components/SampleImageModal'; interface SampleImageCardProps { imageUrl: string; alt: string; + numSamples: number; + sampleImages: string[]; children?: ReactNode; className?: string; onDelete?: () => void; } -const SampleImageCard: React.FC = ({ imageUrl, alt, children, className = '' }) => { +const SampleImageCard: React.FC = ({ imageUrl, alt, numSamples, sampleImages, children, className = '' }) => { const cardRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const [loaded, setLoaded] = useState(false); @@ -43,8 +46,9 @@ const SampleImageCard: React.FC = ({ imageUrl, alt, childr {/* Square image container */}
sampleImageModalState.set({ imgPath: imageUrl, numSamples, sampleImages })} >
{isVisible && ( diff --git a/ui/src/components/SampleImageModal.tsx b/ui/src/components/SampleImageModal.tsx new file mode 100644 index 00000000..1c36f730 --- /dev/null +++ b/ui/src/components/SampleImageModal.tsx @@ -0,0 +1,185 @@ +'use client'; +import { useState, useEffect, useMemo } from 'react'; +import { createGlobalState } from 'react-global-hooks'; +import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; + +export interface SampleImageModalState { + imgPath: string; + numSamples: number; + sampleImages: string[]; +} + +export const sampleImageModalState = createGlobalState(null); + +export const openSampleImage = (sampleImageProps: SampleImageModalState) => { + sampleImageModalState.set(sampleImageProps); +}; + +export default function SampleImageModal() { + const [imageModal, setImageModal] = sampleImageModalState.use(); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + if (imageModal) { + setIsOpen(true); + } + }, [imageModal]); + + useEffect(() => { + if (!isOpen) { + // use timeout to allow the dialog to close before resetting the state + setTimeout(() => { + setImageModal(null); + }, 500); + } + }, [isOpen]); + + const onCancel = () => { + setIsOpen(false); + }; + + const handleArrowUp = () => { + if (!imageModal) return; + console.log('Arrow Up pressed'); + // Change image to same sample but up one step + const currentIdx = imageModal.sampleImages.findIndex(img => img === imageModal.imgPath); + if (currentIdx === -1) return; + const nextIdx = currentIdx - imageModal.numSamples; + if (nextIdx < 0) return; + openSampleImage({ + imgPath: imageModal.sampleImages[nextIdx], + numSamples: imageModal.numSamples, + sampleImages: imageModal.sampleImages, + }); + }; + + const handleArrowDown = () => { + if (!imageModal) return; + console.log('Arrow Down pressed'); + // Change image to same sample but down one step + const currentIdx = imageModal.sampleImages.findIndex(img => img === imageModal.imgPath); + if (currentIdx === -1) return; + const nextIdx = currentIdx + imageModal.numSamples; + if (nextIdx >= imageModal.sampleImages.length) return; + openSampleImage({ + imgPath: imageModal.sampleImages[nextIdx], + numSamples: imageModal.numSamples, + sampleImages: imageModal.sampleImages, + }); + }; + + const handleArrowLeft = () => { + if (!imageModal) return; + console.log('Arrow Left pressed'); + // go to previous sample + const currentIdx = imageModal.sampleImages.findIndex(img => img === imageModal.imgPath); + if (currentIdx === -1) return; + const nextIdx = currentIdx - 1; + if (nextIdx < 0) return; + openSampleImage({ + imgPath: imageModal.sampleImages[nextIdx], + numSamples: imageModal.numSamples, + sampleImages: imageModal.sampleImages, + }); + }; + + const handleArrowRight = () => { + if (!imageModal) return; + console.log('Arrow Right pressed'); + // go to next sample + const currentIdx = imageModal.sampleImages.findIndex(img => img === imageModal.imgPath); + if (currentIdx === -1) return; + const nextIdx = currentIdx + 1; + if (nextIdx >= imageModal.sampleImages.length) return; + openSampleImage({ + imgPath: imageModal.sampleImages[nextIdx], + numSamples: imageModal.numSamples, + sampleImages: imageModal.sampleImages, + }); + }; + + // Handle keyboard events + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!isOpen) return; + + switch (event.key) { + case 'Escape': + onCancel(); + break; + case 'ArrowUp': + handleArrowUp(); + break; + case 'ArrowDown': + handleArrowDown(); + break; + case 'ArrowLeft': + handleArrowLeft(); + break; + case 'ArrowRight': + handleArrowRight(); + break; + default: + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, imageModal]); + + const imgInfo = useMemo(() => { + const ii = { + filename: '', + step: 0, + promptIdx: 0, + }; + if (imageModal?.imgPath) { + const filename = imageModal.imgPath.split('/').pop(); + if (!filename) return ii; + // filename is ___. + ii.filename = filename as string; + const parts = filename + .split('.')[0] + .split('_') + .filter(p => p !== ''); + if (parts.length === 3) { + ii.step = parseInt(parts[1]); + ii.promptIdx = parseInt(parts[2]); + } + } + return ii; + }, [imageModal]); + + return ( + + + +
+
+ +
+ {imageModal?.imgPath && ( + Sample Image + )} +
+
step: {imgInfo.step}
+
+
+
+
+ ); +} diff --git a/ui/src/components/SampleImages.tsx b/ui/src/components/SampleImages.tsx index ff2b1965..c0653001 100644 --- a/ui/src/components/SampleImages.tsx +++ b/ui/src/components/SampleImages.tsx @@ -11,13 +11,13 @@ interface SampleImagesProps { export default function SampleImages({ job }: SampleImagesProps) { const { sampleImages, status, refreshSampleImages } = useSampleImages(job.id, 5000); const numSamples = useMemo(() => { - if (job?.job_config) { - const jobConfig = JSON.parse(job.job_config) as JobConfig; - const sampleConfig = jobConfig.config.process[0].sample; - return sampleConfig.prompts.length; - } - return 10; - }, [job]); + if (job?.job_config) { + const jobConfig = JSON.parse(job.job_config) as JobConfig; + const sampleConfig = jobConfig.config.process[0].sample; + return sampleConfig.prompts.length; + } + return 10; + }, [job]); // Use direct Tailwind class without string interpolation // This way Tailwind can properly generate the class @@ -73,13 +73,19 @@ export default function SampleImages({ job }: SampleImagesProps) { return (
-
+
{status === 'loading' && sampleImages.length === 0 &&

Loading...

} {status === 'error' &&

Error fetching sample images

} {sampleImages && (
{sampleImages.map((sample: string) => ( - + ))}
)}