Merge pull request #1117 from sukarxn/feature/replace-raw-img-tags-with-next/image

fix: optimize image components with next/image
This commit is contained in:
Rohan Verma 2026-04-03 08:15:59 -07:00 committed by GitHub
commit f35f5cef09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 160 additions and 75 deletions

View file

@ -6,6 +6,7 @@ import { ImageIcon, ImageOffIcon } from "lucide-react";
import { memo, type PropsWithChildren, useEffect, useRef, useState } from "react"; import { memo, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import NextImage from 'next/image';
const imageVariants = cva("aui-image-root relative overflow-hidden rounded-lg", { const imageVariants = cva("aui-image-root relative overflow-hidden rounded-lg", {
variants: { variants: {
@ -86,23 +87,57 @@ function ImagePreview({
> >
<ImageOffIcon className="size-8 text-muted-foreground" /> <ImageOffIcon className="size-8 text-muted-foreground" />
</div> </div>
) : ( ) : isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img
ref={imgRef}
src={src}
alt={alt}
className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
onLoad={(e) => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.(e);
}}
onError={(e) => {
if (typeof src === "string") setErrorSrc(src);
onError?.(e);
}}
{...props}
/>
) : (
// biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs // biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs
<img // <img
ref={imgRef} // ref={imgRef}
src={src} // src={src}
alt={alt} // alt={alt}
className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)} // className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
onLoad={(e) => { // onLoad={(e) => {
if (typeof src === "string") setLoadedSrc(src); // if (typeof src === "string") setLoadedSrc(src);
onLoad?.(e); // onLoad?.(e);
}} // }}
onError={(e) => { // onError={(e) => {
if (typeof src === "string") setErrorSrc(src); // if (typeof src === "string") setErrorSrc(src);
onError?.(e); // onError?.(e);
}} // }}
{...props} // {...props}
/> // />
<NextImage
fill
src={src || ""}
alt={alt}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 60vw"
className={cn("block object-contain", !loaded && "invisible", className)}
onLoad={() => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.();
}}
onError={() => {
if (typeof src === "string") setErrorSrc(src);
onError?.();
}}
unoptimized={false}
{...props}
/>
)} )}
</div> </div>
); );
@ -126,7 +161,10 @@ type ImageZoomProps = PropsWithChildren<{
src: string; src: string;
alt?: string; alt?: string;
}>; }>;
function isDataOrBlobUrl(src: string | undefined): boolean {
if (!src || typeof src !== "string") return false;
return src.startsWith("data:") || src.startsWith("blob:");
}
function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) { function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -177,22 +215,39 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
aria-label="Close zoomed image" aria-label="Close zoomed image"
> >
{/** biome-ignore lint/performance/noImgElement: <explanation> */} {/** biome-ignore lint/performance/noImgElement: <explanation> */}
<img {isDataOrBlobUrl(src) ? (
data-slot="image-zoom-content" // biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
src={src} <img
alt={alt} data-slot="image-zoom-content"
className="aui-image-zoom-content fade-in zoom-in-95 max-h-[90vh] max-w-[90vw] animate-in object-contain duration-200" src={src}
onClick={(e) => { alt={alt}
e.stopPropagation(); className="aui-image-zoom-content fade-in zoom-in-95 max-h-[90vh] max-w-[90vw] animate-in object-contain duration-200"
handleClose(); onClick={(e) => {
}} e.stopPropagation();
onKeyDown={(e) => { handleClose();
if (e.key === "Enter") { }}
e.stopPropagation(); onKeyDown={(e) => {
handleClose(); if (e.key === "Enter") {
} e.stopPropagation();
}} handleClose();
/> }
}}
/>
) : (
<NextImage
data-slot="image-zoom-content"
fill
src={src}
alt={alt}
sizes="90vw"
className="aui-image-zoom-content fade-in zoom-in-95 object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
unoptimized={false}
/>
)}
</button>, </button>,
document.body document.body
)} )}

View file

@ -1,4 +1,5 @@
"use client"; "use client";
import Image from 'next/image';
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay"; import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
@ -81,6 +82,15 @@ function UseCaseCard({
alt={title} alt={title}
className="w-full rounded-xl object-cover transition-transform duration-500 group-hover:scale-[1.02]" className="w-full rounded-xl object-cover transition-transform duration-500 group-hover:scale-[1.02]"
/> />
<div className="relative w-full h-48">
<Image
src={src}
alt={title}
fill
className="rounded-xl object-cover transition-transform duration-500 group-hover:scale-[1.02]"
unoptimized={src.endsWith('.gif')}
/>
</div>
</div> </div>
<div className="px-5 py-4"> <div className="px-5 py-4">
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">{title}</h3> <h3 className="text-base font-semibold text-neutral-900 dark:text-white">{title}</h3>

View file

@ -3,6 +3,8 @@ import { createMathPlugin } from "@streamdown/math";
import { Streamdown, type StreamdownProps } from "streamdown"; import { Streamdown, type StreamdownProps } from "streamdown";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Image from 'next/image';
import { is } from "drizzle-orm";
const code = createCodePlugin({ const code = createCodePlugin({
themes: ["nord", "nord"], themes: ["nord", "nord"],
@ -127,16 +129,31 @@ export function MarkdownViewer({ content, className, maxLength }: MarkdownViewer
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} /> <blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />
), ),
hr: ({ ...props }) => <hr className="my-4 border-muted" {...props} />, hr: ({ ...props }) => <hr className="my-4 border-muted" {...props} />,
img: ({ src, alt, width: _w, height: _h, ...props }) => ( img: ({ src, alt, width: _w, height: _h, ...props }) => {
// eslint-disable-next-line @next/next/no-img-element const isDataOrUnknownUrl = typeof src === "string" && (src.startsWith("data:") || !src.startsWith("http"));
<img
className="max-w-full h-auto my-4 rounded" return isDataOrUnknownUrl ? (
alt={alt || "markdown image"} // eslint-disable-next-line @next/next/no-img-element
src={typeof src === "string" ? src : ""} <img
loading="lazy" className="max-w-full h-auto my-4 rounded"
{...props} alt={alt || "markdown image"}
/> src={src}
), loading="lazy"
{...props}
/>
) : (
<Image
className="max-w-full h-auto my-4 rounded"
alt={alt || "markdown image"}
src={typeof src === "string" ? src : ""}
width={_w || 800}
height={_h || 600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
unoptimized={isDataOrUnknownUrl}
{...props}
/>
);
},
table: ({ ...props }) => ( table: ({ ...props }) => (
<div className="overflow-x-auto my-4 rounded-lg border border-border w-full"> <div className="overflow-x-auto my-4 rounded-lg border border-border w-full">
<table className="w-full divide-y divide-border" {...props} /> <table className="w-full divide-y divide-border" {...props} />

View file

@ -7,6 +7,8 @@ import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/med
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import { Citation } from "./citation"; import { Citation } from "./citation";
import type { CitationType, CitationVariant, SerializableCitation } from "./schema"; import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
import NextImage from 'next/image';
const TYPE_ICONS: Record<CitationType, LucideIcon> = { const TYPE_ICONS: Record<CitationType, LucideIcon> = {
webpage: Globe, webpage: Globe,
@ -253,18 +255,18 @@ function OverflowItem({ citation, onClick }: OverflowItemProps) {
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none" className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
> >
{citation.favicon ? ( {citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config <NextImage
<img
src={citation.favicon} src={citation.favicon}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width={16} width={18}
height={16} height={18}
className="bg-muted size-4 shrink-0 rounded object-cover" className="size-4.5 rounded-full object-cover"
unoptimized={true}
/> />
) : ( ) : (
<TypeIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" /> <TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2"> <p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
{citation.title} {citation.title}
@ -339,18 +341,18 @@ function StackedCitations({ id, citations, className, onNavigate }: StackedCitat
style={{ zIndex: maxIcons - index }} style={{ zIndex: maxIcons - index }}
> >
{citation.favicon ? ( {citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config <NextImage
<img src={citation.favicon}
src={citation.favicon} alt=""
alt="" aria-hidden="true"
aria-hidden="true" width={18}
width={18} height={18}
height={18} className="size-4.5 rounded-full object-cover"
className="size-4.5 rounded-full object-cover" unoptimized={true}
/> />
) : ( ) : (
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" /> <TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
)} )}
</div> </div>
); );
})} })}

View file

@ -6,6 +6,7 @@ import * as React from "react";
import { openSafeNavigationHref, sanitizeHref } from "../shared/media"; import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import type { CitationType, CitationVariant, SerializableCitation } from "./schema"; import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
import NextImage from 'next/image';
const FALLBACK_LOCALE = "en-US"; const FALLBACK_LOCALE = "en-US";
@ -114,18 +115,18 @@ export function Citation(props: CitationProps) {
}; };
const iconElement = favicon ? ( const iconElement = favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config <NextImage
<img src={favicon}
src={favicon} alt=""
alt="" aria-hidden="true"
aria-hidden="true" width={16}
width={14} height={16}
height={14} className="bg-muted size-3.5 shrink-0 rounded object-cover"
className="bg-muted size-3.5 shrink-0 rounded object-cover" unoptimized={true}
/> />
) : ( ) : (
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" /> <TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
); );
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover(); const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();