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,8 +87,8 @@ 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: intentional for dynamic external URLs // biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img <img
ref={imgRef} ref={imgRef}
src={src} src={src}
@ -103,6 +104,40 @@ function ImagePreview({
}} }}
{...props} {...props}
/> />
) : (
// biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs
// <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}
// />
<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,6 +215,8 @@ 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> */}
{isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img <img
data-slot="image-zoom-content" data-slot="image-zoom-content"
src={src} src={src}
@ -193,6 +233,21 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
} }
}} }}
/> />
) : (
<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 }) => {
const isDataOrUnknownUrl = typeof src === "string" && (src.startsWith("data:") || !src.startsWith("http"));
return isDataOrUnknownUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
className="max-w-full h-auto my-4 rounded" className="max-w-full h-auto my-4 rounded"
alt={alt || "markdown image"} alt={alt || "markdown image"}
src={typeof src === "string" ? src : ""} src={src}
loading="lazy" loading="lazy"
{...props} {...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,17 +255,17 @@ 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">
@ -339,14 +341,14 @@ 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" />

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,14 +115,14 @@ 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={14} width={16}
height={14} height={16}
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" />