🏞️ fix: Image Preview Refactor with Accessibility Enhancements (#11217)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* 🔧 fix: Prevent race condition by saving user messages before final event in ResumableAgentController

- Updated the ResumableAgentController to save user messages prior to sending the final event. This change addresses a potential race condition where the client might refetch data before the database is updated.
- Removed redundant message saving logic that was previously located after the final event handling, ensuring a more reliable message processing flow.

* style: improve image preview dialogs with ChatGPT-like UX and accessibility

Refactored image preview dialogs (DialogImage and ImagePreview) to provide
a cleaner, more intuitive user experience similar to ChatGPT's implementation.

## DialogImage.tsx (generated images)
- Replaced OGDialog/OGDialogContent with direct Radix Dialog primitives
  for finer control over behavior
- Full-screen dark overlay (bg-black/90) that closes on click outside image
- Restructured component so all interactive elements (close, download,
  details panel buttons) are inside DialogPrimitive.Content for proper
  focus trap and keyboard navigation
- Added onOpenAutoFocus to focus close button when dialog opens
- Added onCloseAutoFocus to return focus to trigger element on close
- Added triggerRef prop to enable focus restoration
- Removed animate-in/animate-out classes that caused stuttering on open
- Changed transition-all to transition-[margin] to prevent animation jank
- Added proper TypeScript types for component props

## ImagePreview.tsx (uploaded file thumbnails)
- Same Radix Dialog primitive refactor for consistent behavior
- Click-outside-to-close functionality
- Proper focus management with closeButtonRef and triggerRef
- Made button the container element to prevent focus ring clipping
- Added focus-visible ring styling for keyboard navigation visibility

## Image.tsx (image display component)
- Restructured so button is the outer container instead of being nested
  inside a div with overflow-hidden (which was clipping focus ring)
- Added visible focus-visible:ring styling with ring-offset
- Added aria-haspopup="dialog" for screen reader context
- Added triggerRef and passed to DialogImage for focus restoration

## Accessibility improvements
- Keyboard navigation now works properly (Tab cycles through buttons)
- Escape key closes dialog (or resets zoom if zoomed in)
- Focus is trapped within dialog when open
- Focus returns to trigger element when dialog closes
- Visible focus indicators on image buttons when focused via keyboard
- Proper ARIA attributes (aria-label, aria-haspopup, aria-hidden)

## UX improvements
- Click anywhere outside the image to close (not just specific regions)
- No more weird scroll/navigation issues
- Instant dialog open without stuttering animations
- Clean, minimal overlay without container/header chrome

* refactor: Improve click handling in image preview dialogs

Updated the click handling logic in ImagePreview and DialogImage components to ensure that the dialog only closes when clicking directly on the overlay or content background, enhancing user experience by preventing unintended closures when interacting with child elements. Additionally, clarified comments to reflect the new behavior.

* chore: import order
This commit is contained in:
Danny Avila 2026-01-05 16:31:35 -05:00 committed by GitHub
parent 019c59f10e
commit d21dfba2ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 355 additions and 242 deletions

View file

@ -264,6 +264,14 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
isNewConvo &&
!wasAbortedBeforeComplete;
// Save user message BEFORE sending final event to avoid race condition
// where client refetch happens before database is updated
if (!client.skipSaveUserMessage && userMessage) {
await saveMessage(req, userMessage, {
context: 'api/server/controllers/agents/request.js - resumable user message',
});
}
if (!wasAbortedBeforeComplete) {
const finalEvent = {
final: true,
@ -298,12 +306,6 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
await decrementPendingRequest(userId);
}
if (!client.skipSaveUserMessage && userMessage) {
await saveMessage(req, userMessage, {
context: 'api/server/controllers/agents/request.js - resumable user message',
});
}
if (shouldGenerateTitle) {
addTitle(req, {
text,

View file

@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Maximize2 } from 'lucide-react';
import { Button } from '@librechat/client';
import { Maximize2, X } from 'lucide-react';
import { FileSources } from 'librechat-data-provider';
import { OGDialog, OGDialogContent } from '@librechat/client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import ProgressCircle from './ProgressCircle';
import SourceIcon from './SourceIcon';
import { cn } from '~/utils';
@ -31,6 +32,8 @@ const ImagePreview = ({
const [isModalOpen, setIsModalOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const openModal = useCallback(() => {
setIsModalOpen(true);
@ -45,6 +48,16 @@ const ImagePreview = ({
}
}, []);
// Handle click on background areas to close (only if clicking the overlay/content directly)
const handleBackgroundClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
handleOpenChange(false);
}
},
[handleOpenChange],
);
useEffect(() => {
if (isModalOpen) {
document.body.style.overflow = 'hidden';
@ -57,6 +70,18 @@ const ImagePreview = ({
};
}, [isModalOpen]);
// Handle escape key
useEffect(() => {
if (!isModalOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleOpenChange(false);
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [isModalOpen, handleOpenChange]);
const baseStyle: styleProps = {
backgroundSize: 'cover',
backgroundPosition: 'center',
@ -85,24 +110,25 @@ const ImagePreview = ({
return (
<>
<div
className={cn('relative size-14 rounded-xl', className)}
<button
ref={triggerRef}
type="button"
className={cn(
'relative size-14 overflow-hidden rounded-xl transition-shadow',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary',
className,
)}
style={style}
aria-label={`View ${alt} in full size`}
aria-haspopup="dialog"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openModal();
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<button
ref={triggerRef}
type="button"
className="size-full overflow-hidden rounded-xl"
style={style}
aria-label={`View ${alt} in full size`}
aria-haspopup="dialog"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openModal();
}}
/>
{progress < 1 ? (
<ProgressCircle
circumference={circumference}
@ -116,10 +142,6 @@ const ImagePreview = ({
'absolute inset-0 flex transform-gpu cursor-pointer items-center justify-center rounded-xl transition-opacity duration-200 ease-in-out',
isHovered ? 'bg-black/20 opacity-100' : 'opacity-0',
)}
onClick={(e) => {
e.stopPropagation();
openModal();
}}
aria-hidden="true"
>
<Maximize2
@ -131,21 +153,51 @@ const ImagePreview = ({
</div>
)}
<SourceIcon source={source} aria-label={source ? `Source: ${source}` : undefined} />
</div>
</button>
<OGDialog open={isModalOpen} onOpenChange={handleOpenChange}>
<OGDialogContent
showCloseButton={false}
className="w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto"
disableScroll={false}
>
<img
src={imageUrl}
alt={alt}
className="max-w-screen h-full max-h-screen w-full object-contain"
<DialogPrimitive.Root open={isModalOpen} onOpenChange={handleOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className="fixed inset-0 z-[100] bg-black/90"
onClick={handleBackgroundClick}
/>
</OGDialogContent>
</OGDialog>
<DialogPrimitive.Content
className="fixed inset-0 z-[100] flex items-center justify-center outline-none"
onOpenAutoFocus={(e) => {
e.preventDefault();
closeButtonRef.current?.focus();
}}
onCloseAutoFocus={(e) => {
e.preventDefault();
triggerRef.current?.focus();
}}
onPointerDownOutside={(e) => e.preventDefault()}
onClick={handleBackgroundClick}
>
{/* Close button */}
<Button
ref={closeButtonRef}
onClick={() => handleOpenChange(false)}
variant="ghost"
className="absolute right-4 top-4 z-20 h-10 w-10 p-0 text-white hover:bg-white/10"
aria-label="Close"
>
<X className="size-5" aria-hidden="true" />
</Button>
{/* Image container */}
<div onClick={(e) => e.stopPropagation()}>
<img
ref={imageRef}
src={imageUrl}
alt={alt}
className="max-h-[85vh] max-w-[90vw] object-contain"
draggable={false}
/>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</>
);
};

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Button, OGDialog, OGDialogContent, TooltipAnchor } from '@librechat/client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Button, TooltipAnchor } from '@librechat/client';
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
import { useLocalize } from '~/hooks';
@ -13,7 +14,26 @@ const getQualityStyles = (quality: string): string => {
return 'bg-gray-100 text-gray-800';
};
export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage, args }) {
export default function DialogImage({
isOpen,
onOpenChange,
src = '',
downloadImage,
args,
triggerRef,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
src?: string;
downloadImage: () => void;
args?: {
prompt?: string;
quality?: string;
size?: string;
[key: string]: unknown;
};
triggerRef?: React.RefObject<HTMLButtonElement>;
}) {
const localize = useLocalize();
const [isPromptOpen, setIsPromptOpen] = useState(false);
const [imageSize, setImageSize] = useState<string | null>(null);
@ -26,6 +46,8 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const getImageSize = useCallback(async (url: string) => {
try {
@ -56,15 +78,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getImageMaxWidth = () => {
// On mobile (when panel overlays), use full width minus padding
// On desktop, account for the side panel width
if (isPromptOpen) {
return window.innerWidth >= 640 ? 'calc(100vw - 22rem)' : 'calc(100vw - 2rem)';
}
return 'calc(100vw - 2rem)';
};
const resetZoom = useCallback(() => {
setZoom(1);
setPanX(0);
@ -80,7 +93,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
if (zoom > 1) {
resetZoom();
} else {
// Zoom in to 2x on double click when at normal zoom
setZoom(2);
}
}, [zoom, resetZoom]);
@ -94,13 +106,11 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Calculate zoom factor
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.min(Math.max(zoom * zoomFactor, 1), 5);
if (newZoom === zoom) return;
// If zooming back to 1, reset pan to center the image
if (newZoom === 1) {
setZoom(1);
setPanX(0);
@ -108,11 +118,9 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
return;
}
// Calculate the zoom center relative to the current viewport
const containerCenterX = rect.width / 2;
const containerCenterY = rect.height / 2;
// Calculate new pan position to zoom towards mouse cursor
const zoomRatio = newZoom / zoom;
const deltaX = (mouseX - containerCenterX - panX) * (zoomRatio - 1);
const deltaY = (mouseY - containerCenterY - panY) * (zoomRatio - 1);
@ -147,15 +155,41 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
},
[isDragging, dragStart, zoom],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
// Handle click on empty areas to close (only if clicking overlay/content directly, not children)
const handleBackgroundClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only close if clicking directly on overlay/content background
if (e.target !== e.currentTarget) {
return;
}
// Don't close if zoomed (user might be panning)
if (zoom > 1) {
return;
}
onOpenChange(false);
},
[onOpenChange, zoom],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && resetZoom();
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (zoom > 1) {
resetZoom();
} else {
onOpenChange(false);
}
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [resetZoom]);
}, [resetZoom, onOpenChange, isOpen, zoom]);
useEffect(() => {
if (isOpen && src) {
@ -164,7 +198,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
}
}, [isOpen, src, getImageSize, resetZoom]);
// Ensure image is centered when zoom changes to 1
useEffect(() => {
if (zoom === 1) {
setPanX(0);
@ -172,7 +205,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
}
}, [zoom]);
// Reset pan when panel opens/closes to maintain centering
useEffect(() => {
if (zoom === 1) {
setPanX(0);
@ -180,35 +212,75 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
}
}, [isPromptOpen, zoom]);
// Lock body scroll when dialog is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
const imageDetailsLabel = isPromptOpen
? localize('com_ui_hide_image_details')
: localize('com_ui_show_image_details');
// Calculate image max dimensions accounting for side panel (w-80 = 320px)
const getImageMaxWidth = () => {
if (isPromptOpen) {
// On mobile, panel overlays so use full width; on desktop, subtract panel width
return typeof window !== 'undefined' && window.innerWidth >= 640
? 'calc(90vw - 320px)'
: '90vw';
}
return '90vw';
};
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogContent
showCloseButton={false}
className="h-full w-full rounded-none bg-transparent"
disableScroll={false}
overlayClassName="bg-surface-primary opacity-95 z-50"
>
<div
className={`ease-[cubic-bezier(0.175,0.885,0.32,1.275)] absolute left-0 top-0 z-10 flex items-center justify-between p-3 transition-all duration-500 sm:p-4 ${isPromptOpen ? 'right-0 sm:right-80' : 'right-0'}`}
<DialogPrimitive.Root open={isOpen} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className="fixed inset-0 z-[100] bg-black/90"
onClick={handleBackgroundClick}
/>
<DialogPrimitive.Content
className="fixed inset-0 z-[100] flex items-center justify-center outline-none"
onOpenAutoFocus={(e) => {
e.preventDefault();
closeButtonRef.current?.focus();
}}
onCloseAutoFocus={(e) => {
e.preventDefault();
triggerRef?.current?.focus();
}}
onPointerDownOutside={(e) => e.preventDefault()}
onClick={handleBackgroundClick}
>
<TooltipAnchor
description={localize('com_ui_close')}
render={
<Button
onClick={() => onOpenChange(false)}
variant="ghost"
className="h-10 w-10 p-0 hover:bg-surface-hover"
aria-label={localize('com_ui_close')}
>
<X className="size-7 sm:size-6" aria-hidden="true" />
</Button>
}
/>
<div className="flex items-center gap-1 sm:gap-2">
{/* Close button - top left */}
<div className="absolute left-4 top-4 z-20">
<TooltipAnchor
description={localize('com_ui_close')}
render={
<Button
ref={closeButtonRef}
onClick={() => onOpenChange(false)}
variant="ghost"
className="h-10 w-10 p-0 text-white hover:bg-white/10"
aria-label={localize('com_ui_close')}
>
<X className="size-6" aria-hidden="true" />
</Button>
}
/>
</div>
{/* Action buttons - top right (336px = 320px panel + 16px gap) */}
<div
className={`absolute top-4 z-20 flex items-center gap-2 transition-[right] duration-300 ${isPromptOpen ? 'right-[336px]' : 'right-4'}`}
>
{zoom > 1 && (
<TooltipAnchor
description={localize('com_ui_reset_zoom')}
@ -216,10 +288,10 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
<Button
onClick={resetZoom}
variant="ghost"
className="h-10 w-10 p-0"
className="h-10 w-10 p-0 text-white hover:bg-white/10"
aria-label={localize('com_ui_reset_zoom')}
>
<RotateCcw className="size-6" aria-hidden="true" />
<RotateCcw className="size-5" aria-hidden="true" />
</Button>
}
/>
@ -230,10 +302,10 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
<Button
onClick={() => downloadImage()}
variant="ghost"
className="h-10 w-10 p-0"
className="h-10 w-10 p-0 text-white hover:bg-white/10"
aria-label={localize('com_ui_download')}
>
<ArrowDownToLine className="size-6" aria-hidden="true" />
<ArrowDownToLine className="size-5" aria-hidden="true" />
</Button>
}
/>
@ -243,143 +315,129 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
<Button
onClick={() => setIsPromptOpen(!isPromptOpen)}
variant="ghost"
className="h-10 w-10 p-0"
className="h-10 w-10 p-0 text-white hover:bg-white/10"
aria-label={imageDetailsLabel}
>
{isPromptOpen ? (
<PanelLeftOpen className="size-7 sm:size-6" aria-hidden="true" />
<PanelLeftOpen className="size-5" aria-hidden="true" />
) : (
<PanelLeftClose className="size-7 sm:size-6" aria-hidden="true" />
<PanelLeftClose className="size-5" aria-hidden="true" />
)}
</Button>
}
/>
</div>
</div>
{/* Main content area with image */}
<div
className={`ease-[cubic-bezier(0.175,0.885,0.32,1.275)] flex h-full transition-all duration-500 ${isPromptOpen ? 'mr-0 sm:mr-80' : 'mr-0'}`}
>
{/* Image container - centered */}
<div
ref={containerRef}
className="flex flex-1 items-center justify-center px-2 pb-4 pt-16 sm:px-4 sm:pt-20"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDoubleClick={handleDoubleClick}
style={{
cursor: getCursor(),
overflow: zoom > 1 ? 'hidden' : 'visible',
minHeight: 0, // Allow flexbox to shrink
}}
className={`transition-[margin] duration-300 ${isPromptOpen ? 'mr-80' : ''}`}
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-center transition-transform duration-100 ease-out"
style={{
transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
transformOrigin: 'center center',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
ref={containerRef}
className="relative"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDoubleClick={handleDoubleClick}
style={{ cursor: getCursor() }}
>
<img
src={src}
alt="Image"
className="block object-contain"
<div
className="transition-transform duration-100 ease-out"
style={{
maxHeight: 'calc(100vh - 8rem)',
maxWidth: getImageMaxWidth(),
width: 'auto',
height: 'auto',
transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
transformOrigin: 'center center',
}}
/>
</div>
</div>
</div>
{/* Side Panel */}
<div
className={`sm:shadow-l-lg ease-[cubic-bezier(0.175,0.885,0.32,1.275)] fixed right-0 top-0 z-20 h-full w-full transform border-l border-border-light bg-surface-primary shadow-2xl backdrop-blur-sm transition-transform duration-500 sm:w-80 sm:rounded-l-2xl ${
isPromptOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Mobile pull handle - removed for cleaner look */}
<div className="h-full overflow-y-auto p-4 sm:p-6">
{/* Mobile close button */}
<div className="mb-4 flex items-center justify-between sm:hidden">
<h3 className="text-lg font-semibold text-text-primary">
{localize('com_ui_image_details')}
</h3>
<Button
onClick={() => setIsPromptOpen(false)}
variant="ghost"
className="h-12 w-12 p-0"
>
<X className="size-6" aria-hidden="true" />
</Button>
</div>
<div className="mb-4 hidden sm:block">
<h3 className="mb-2 text-lg font-semibold text-text-primary">
{localize('com_ui_image_details')}
</h3>
<div className="mb-4 h-px bg-border-medium"></div>
</div>
<div className="space-y-4 sm:space-y-6">
{/* Prompt Section */}
<div>
<h4 className="mb-2 text-sm font-medium text-text-primary">
{localize('com_ui_prompt')}
</h4>
<div className="rounded-md bg-surface-tertiary p-3">
<p className="text-sm leading-relaxed text-text-primary">
{args?.prompt || 'No prompt available'}
</p>
</div>
<img
ref={imageRef}
src={src}
alt="Image"
className="block max-h-[85vh] object-contain"
style={{
maxWidth: getImageMaxWidth(),
}}
draggable={false}
/>
</div>
</div>
</div>
{/* Generation Settings */}
<div>
<h4 className="mb-3 text-sm font-medium text-text-primary">
{localize('com_ui_generation_settings')}
</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-text-primary">{localize('com_ui_size')}:</span>
<span className="text-sm font-medium text-text-primary">
{args?.size || 'Unknown'}
</span>
{/* Side Panel */}
<div
data-side-panel
className={`fixed right-0 top-0 z-30 h-full w-80 transform border-l border-white/10 bg-surface-primary shadow-2xl transition-transform duration-300 ${
isPromptOpen ? 'translate-x-0' : 'translate-x-full'
}`}
onClick={(e) => e.stopPropagation()}
>
<div className="h-full overflow-y-auto p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-text-primary">
{localize('com_ui_image_details')}
</h3>
<Button
onClick={() => setIsPromptOpen(false)}
variant="ghost"
className="h-10 w-10 p-0 sm:hidden"
>
<X className="size-5" aria-hidden="true" />
</Button>
</div>
<div className="mb-4 h-px bg-border-medium"></div>
<div className="space-y-6">
{/* Prompt Section */}
<div>
<h4 className="mb-2 text-sm font-medium text-text-primary">
{localize('com_ui_prompt')}
</h4>
<div className="rounded-md bg-surface-tertiary p-3">
<p className="text-sm leading-relaxed text-text-primary">
{args?.prompt || 'No prompt available'}
</p>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-primary">{localize('com_ui_quality')}:</span>
<span
className={`rounded px-2 py-1 text-xs font-medium capitalize ${getQualityStyles(args?.quality || '')}`}
>
{args?.quality || 'Standard'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-primary">
{localize('com_ui_file_size')}:
</span>
<span className="text-sm font-medium text-text-primary">
{imageSize || 'Loading...'}
</span>
</div>
{/* Generation Settings */}
<div>
<h4 className="mb-3 text-sm font-medium text-text-primary">
{localize('com_ui_generation_settings')}
</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-text-primary">{localize('com_ui_size')}:</span>
<span className="text-sm font-medium text-text-primary">
{args?.size || 'Unknown'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-primary">
{localize('com_ui_quality')}:
</span>
<span
className={`rounded px-2 py-1 text-xs font-medium capitalize ${getQualityStyles(args?.quality || '')}`}
>
{args?.quality || 'Standard'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-primary">
{localize('com_ui_file_size')}:
</span>
<span className="text-sm font-medium text-text-primary">
{imageSize || 'Loading...'}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</OGDialogContent>
</OGDialog>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View file

@ -34,6 +34,7 @@ const Image = ({
const [isOpen, setIsOpen] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const handleImageLoad = () => setIsLoaded(true);
@ -96,52 +97,52 @@ const Image = ({
return (
<div ref={containerRef}>
<div
<button
ref={triggerRef}
type="button"
aria-label={`View ${altText} in dialog`}
aria-haspopup="dialog"
onClick={() => setIsOpen(true)}
className={cn(
'relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md',
'relative mt-1 flex h-auto w-full max-w-lg cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md transition-shadow',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary',
className,
)}
>
<button
type="button"
aria-label={`View ${altText} in dialog`}
onClick={() => setIsOpen(true)}
className="cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<LazyLoadImage
alt={altText}
onLoad={handleImageLoad}
visibleByDefault={true}
className={cn(
'opacity-100 transition-opacity duration-100',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
src={absoluteImageUrl}
style={{
width: `${scaledWidth}`,
height: 'auto',
color: 'transparent',
display: 'block',
}}
placeholder={
<Skeleton
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
aria-label="Loading image"
aria-busy="true"
/>
}
/>
</button>
{isLoaded && (
<DialogImage
isOpen={isOpen}
onOpenChange={setIsOpen}
src={absoluteImageUrl}
downloadImage={downloadImage}
args={args}
/>
)}
</div>
<LazyLoadImage
alt={altText}
onLoad={handleImageLoad}
visibleByDefault={true}
className={cn(
'opacity-100 transition-opacity duration-100',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
src={absoluteImageUrl}
style={{
width: `${scaledWidth}`,
height: 'auto',
color: 'transparent',
display: 'block',
}}
placeholder={
<Skeleton
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
aria-label="Loading image"
aria-busy="true"
/>
}
/>
</button>
{isLoaded && (
<DialogImage
isOpen={isOpen}
onOpenChange={setIsOpen}
src={absoluteImageUrl}
downloadImage={downloadImage}
args={args}
triggerRef={triggerRef}
/>
)}
</div>
);
};