Image lightbox for chat attachments (#5441)
* add image lightbox for chat attachments * wrap lightbox image triggers in button elements * add images to dependency array * add jsdoc to ChatAttachments and remove filter
This commit is contained in:
parent
1cea4df8e6
commit
6ca2d5235d
@ -12,6 +12,7 @@ import { FullScreenLoader } from "./components/Preloader";
|
|||||||
import { ThemeProvider } from "./ThemeContext";
|
import { ThemeProvider } from "./ThemeContext";
|
||||||
import { PWAModeProvider } from "./PWAContext";
|
import { PWAModeProvider } from "./PWAContext";
|
||||||
import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp";
|
import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp";
|
||||||
|
import ImageLightbox from "@/components/ImageLightbox";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
|
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ export default function App() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<KeyboardShortcutsHelp />
|
<KeyboardShortcutsHelp />
|
||||||
|
<ImageLightbox />
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</PfpProvider>
|
</PfpProvider>
|
||||||
</LogoProvider>
|
</LogoProvider>
|
||||||
|
|||||||
115
frontend/src/components/ImageLightbox/index.jsx
Normal file
115
frontend/src/components/ImageLightbox/index.jsx
Normal file
@ -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(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90"
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
className="absolute top-4 right-4 p-2 text-white light:text-white hover:text-white/70 transition-colors rounded-full bg-white/10 hover:bg-white/20 border-none cursor-pointer"
|
||||||
|
aria-label="Close lightbox"
|
||||||
|
>
|
||||||
|
<X size={24} weight="bold" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePrevious();
|
||||||
|
}}
|
||||||
|
className="absolute left-4 p-3 text-white light:text-white hover:text-white/70 transition-colors rounded-full bg-white/10 hover:bg-white/20 border-none cursor-pointer"
|
||||||
|
aria-label="Previous image"
|
||||||
|
>
|
||||||
|
<CaretLeft size={24} weight="bold" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleNext();
|
||||||
|
}}
|
||||||
|
className="absolute right-4 p-3 text-white light:text-white hover:text-white/70 transition-colors rounded-full bg-white/10 hover:bg-white/20 border-none cursor-pointer"
|
||||||
|
aria-label="Next image"
|
||||||
|
>
|
||||||
|
<CaretRight size={24} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={currentImage.contentString}
|
||||||
|
alt={currentImage.name || "attachment"}
|
||||||
|
className="max-w-[90vw] max-h-[90vh] object-contain"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/70 text-sm bg-black/50 px-3 py-1 rounded-full">
|
||||||
|
{safeIndex + 1} / {images.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.getElementById("root")
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { chatQueryRefusalResponse } from "@/utils/chat";
|
import { chatQueryRefusalResponse } from "@/utils/chat";
|
||||||
import HistoricalOutputs from "./HistoricalOutputs";
|
import HistoricalOutputs from "./HistoricalOutputs";
|
||||||
|
import { openImageLightbox } from "@/components/ImageLightbox";
|
||||||
|
|
||||||
const HistoricalMessage = ({
|
const HistoricalMessage = ({
|
||||||
uuid = v4(),
|
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 = [] }) {
|
function ChatAttachments({ attachments = [] }) {
|
||||||
if (!attachments.length) return null;
|
if (!attachments.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-4 mt-4">
|
<div className="flex flex-wrap gap-4 mt-4">
|
||||||
{attachments.map((item) => (
|
{attachments.map((item, index) => (
|
||||||
<img
|
<button
|
||||||
alt={`Attachment: ${item.name}`}
|
type="button"
|
||||||
key={item.name}
|
key={item.name}
|
||||||
src={item.contentString}
|
onClick={() => openImageLightbox(attachments, index)}
|
||||||
className="w-[120px] h-[120px] object-cover rounded-lg"
|
className="p-0 border-none bg-transparent cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
/>
|
>
|
||||||
|
<img
|
||||||
|
alt={`Attachment: ${item.name}`}
|
||||||
|
src={item.contentString}
|
||||||
|
className="w-[120px] h-[120px] object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper";
|
import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper";
|
||||||
|
import { openImageLightbox } from "@/components/ImageLightbox";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{attachments: import("../../DnDWrapper").Attachment[]}}
|
* @param {{attachments: import("../../DnDWrapper").Attachment[]}}
|
||||||
@ -18,10 +19,25 @@ import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper";
|
|||||||
*/
|
*/
|
||||||
export default function AttachmentManager({ attachments }) {
|
export default function AttachmentManager({ attachments }) {
|
||||||
if (attachments.length === 0) return null;
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap gap-2 mt-2 mb-4">
|
<div className="flex flex-wrap gap-2 mt-2 mb-4">
|
||||||
{attachments.map((attachment) => (
|
{attachments.map((attachment) => (
|
||||||
<AttachmentItem key={attachment.uid} attachment={attachment} />
|
<AttachmentItem
|
||||||
|
key={attachment.uid}
|
||||||
|
attachment={attachment}
|
||||||
|
onImageClick={() => handleImageClick(attachment)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -30,7 +46,7 @@ export default function AttachmentManager({ attachments }) {
|
|||||||
/**
|
/**
|
||||||
* @param {{attachment: import("../../DnDWrapper").Attachment}}
|
* @param {{attachment: import("../../DnDWrapper").Attachment}}
|
||||||
*/
|
*/
|
||||||
function AttachmentItem({ attachment }) {
|
function AttachmentItem({ attachment, onImageClick }) {
|
||||||
const { uid, file, status, error, document, type, contentString } =
|
const { uid, file, status, error, document, type, contentString } =
|
||||||
attachment;
|
attachment;
|
||||||
const { iconBgColor, Icon } = displayFromFile(file);
|
const { iconBgColor, Icon } = displayFromFile(file);
|
||||||
@ -115,12 +131,18 @@ function AttachmentItem({ attachment }) {
|
|||||||
<X size={10} className="flex-shrink-0" />
|
<X size={10} className="flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<button
|
||||||
alt={`Preview of ${file.name}`}
|
type="button"
|
||||||
src={contentString}
|
onClick={onImageClick}
|
||||||
style={{ objectFit: "cover", objectPosition: "center" }}
|
className="p-0 border-none bg-transparent cursor-pointer"
|
||||||
className={`${iconBgColor} w-[40px] h-[40px] rounded-lg flex items-center justify-center`}
|
>
|
||||||
/>
|
<img
|
||||||
|
alt={`Preview of ${file.name}`}
|
||||||
|
src={contentString}
|
||||||
|
style={{ objectFit: "cover", objectPosition: "center" }}
|
||||||
|
className={`${iconBgColor} w-[40px] h-[40px] rounded-lg flex items-center justify-center`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user