diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7e7b58a5b..32fc4b4f6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import { FullScreenLoader } from "./components/Preloader"; import { ThemeProvider } from "./ThemeContext"; import { PWAModeProvider } from "./PWAContext"; import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp"; +import ImageLightbox from "@/components/ImageLightbox"; import { ErrorBoundary } from "react-error-boundary"; import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback"; @@ -33,6 +34,7 @@ export default function App() { + diff --git a/frontend/src/components/ImageLightbox/index.jsx b/frontend/src/components/ImageLightbox/index.jsx new file mode 100644 index 000000000..bd91cd076 --- /dev/null +++ b/frontend/src/components/ImageLightbox/index.jsx @@ -0,0 +1,115 @@ +import { useState, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { X, CaretLeft, CaretRight } from "@phosphor-icons/react"; + +const OPEN_EVENT = "open-image-lightbox"; + +/** + * Opens the image lightbox from anywhere in the app. + * @param {{contentString: string, name: string}[]} images + * @param {number} initialIndex + */ +export function openImageLightbox(images, initialIndex = 0) { + window.dispatchEvent( + new CustomEvent(OPEN_EVENT, { detail: { images, initialIndex } }) + ); +} + +export default function ImageLightbox() { + const [images, setImages] = useState(null); + const [currentIndex, setCurrentIndex] = useState(0); + + useEffect(() => { + function handleOpen(e) { + setImages(e.detail.images); + setCurrentIndex(e.detail.initialIndex); + } + window.addEventListener(OPEN_EVENT, handleOpen); + return () => window.removeEventListener(OPEN_EVENT, handleOpen); + }, []); + + function close() { + setImages(null); + } + + function handlePrevious() { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1)); + } + + function handleNext() { + setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0)); + } + + useEffect(() => { + if (!images) return; + function handleKeyDown(e) { + if (e.key === "Escape") close(); + else if (e.key === "ArrowLeft") handlePrevious(); + else if (e.key === "ArrowRight") handleNext(); + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [images]); + + if (!images || images.length === 0) return null; + const safeIndex = Math.min(currentIndex, images.length - 1); + const currentImage = images[safeIndex]; + if (!currentImage) return null; + + return createPortal( +
+ + + {images.length > 1 && ( + <> + + + + )} + + {currentImage.name e.stopPropagation()} + /> + + {images.length > 1 && ( +
+ {safeIndex + 1} / {images.length} +
+ )} +
, + document.getElementById("root") + ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index c6c581753..53a0d751f 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx @@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { chatQueryRefusalResponse } from "@/utils/chat"; import HistoricalOutputs from "./HistoricalOutputs"; +import { openImageLightbox } from "@/components/ImageLightbox"; const HistoricalMessage = ({ uuid = v4(), @@ -205,17 +206,27 @@ export default memo( } ); +/** + * Currently only renders image attachments as clickable thumbnails that open in the lightbox. + * Other attachment types may be supported here in the future. + */ function ChatAttachments({ attachments = [] }) { if (!attachments.length) return null; return (
- {attachments.map((item) => ( - {`Attachment: ( + ))}
); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx index 6b1e2d1b3..d4e6f5bad 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx @@ -11,6 +11,7 @@ import { X, } from "@phosphor-icons/react"; import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper"; +import { openImageLightbox } from "@/components/ImageLightbox"; /** * @param {{attachments: import("../../DnDWrapper").Attachment[]}} @@ -18,10 +19,25 @@ import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper"; */ export default function AttachmentManager({ attachments }) { if (attachments.length === 0) return null; + + function handleImageClick(attachment) { + const imageAttachments = attachments + .filter((a) => a.type === "attachment" && a.contentString) + .map((a) => ({ contentString: a.contentString, name: a.file.name })); + const idx = imageAttachments.findIndex( + (img) => img.name === attachment.file?.name + ); + if (idx !== -1) openImageLightbox(imageAttachments, idx); + } + return (
{attachments.map((attachment) => ( - + handleImageClick(attachment)} + /> ))}
); @@ -30,7 +46,7 @@ export default function AttachmentManager({ attachments }) { /** * @param {{attachment: import("../../DnDWrapper").Attachment}} */ -function AttachmentItem({ attachment }) { +function AttachmentItem({ attachment, onImageClick }) { const { uid, file, status, error, document, type, contentString } = attachment; const { iconBgColor, Icon } = displayFromFile(file); @@ -115,12 +131,18 @@ function AttachmentItem({ attachment }) { - {`Preview + ); }