Enable Next.js image optimization across ~60 remaining unoptimized <Image> spots
- Remove `unoptimized` from every Image that was pointing at a real remote/ static file (Reels image carousel, story avatars/viewer, shop product grids/details, chat shared-post/product/story bubbles, notification/ visitor rows, tag/mention pickers, etc.) so Next's own resize+avif/webp pipeline (already configured in next.config.ts) actually applies — previously only a handful of components used it, everything else shipped full-resolution originals regardless of the tiny thumbnail size shown - Add a `sizes` prop wherever `fill` was used without one, so the optimizer picks an appropriately small srcset entry instead of defaulting to 100vw - Fix ProfileAvatar to request the actual displayed size instead of always requesting 280x280 regardless of the `size` prop - Left `unoptimized` on the handful of genuinely local/ephemeral sources (blob:/data: URIs from in-progress uploads, third-party QR API images, static SVG icons) where Next's optimizer can't or shouldn't apply - Add next-story prefetch to StoryViewer (previously only Reels prefetched upcoming media; swiping to the next story always cold-started before) - Fix .claude/launch.json's dev server port (3000 -> 3004) to match package.json's actual `next dev --port 3004` Verified via `tsc --noEmit` (no new errors) and by diffing this app's console/network behavior against a git-stashed baseline in the browser preview — the one pre-existing hydration warning and 401s (unauthenticated session) are present on both, and /_next/image requests now succeed for avatars/thumbnails that previously bypassed optimization entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -97,7 +97,6 @@ function SpecialistCard({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "280px" : "120px"}
|
||||
unoptimized
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -144,7 +143,6 @@ function SpecialistCard({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "64px" : "36px"}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
|
||||
@@ -152,7 +152,6 @@ function ArchivePage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -66,8 +66,8 @@ function BlockedUsersPage() {
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="44px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -296,7 +296,6 @@ export default function ProductManagePage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -96,7 +96,6 @@ export default function CheckoutPage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="64px"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -347,7 +347,6 @@ export default function ListingDetailClient() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -356,7 +356,6 @@ export default function OrderDetailPage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="64px"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -85,7 +85,6 @@ export default function ProductComparisonPage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="64px"
|
||||
unoptimized
|
||||
/>
|
||||
{discountPercent != null && (
|
||||
<DiscountBadge
|
||||
|
||||
@@ -350,7 +350,6 @@ export default function ShopProfileClient() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="33vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -138,7 +138,6 @@ function MainBillboardCard({
|
||||
height={120}
|
||||
alt={billboard?.title}
|
||||
src={`${IMAGE_BASE_URL}${billboard.images[0]}`}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-full bg-gray-300 flex items-center justify-center h-[86px] w-[86px] md:h-[120px] md:w-[120px] mt-4">
|
||||
|
||||
@@ -52,8 +52,8 @@ export default function SharedPostBubble({ data }: { data: SharedPostPayload })
|
||||
src={preview}
|
||||
alt=""
|
||||
fill
|
||||
sizes="240px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function SharedProductBubble({ data }: { data: SharedProductPaylo
|
||||
>
|
||||
{preview && (
|
||||
<div className="relative aspect-[4/3] w-full min-w-[200px] max-w-[240px] bg-neutral-900">
|
||||
<Image src={preview} alt="" fill className="object-cover" unoptimized />
|
||||
<Image src={preview} alt="" fill sizes="240px" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="px-2 py-1.5">
|
||||
|
||||
@@ -79,8 +79,8 @@ export default function SharedStoryBubble({
|
||||
src={preview}
|
||||
alt=""
|
||||
fill
|
||||
sizes="120px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,6 @@ export default function ExploreVideoThumb({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
) : !frameReady ? (
|
||||
<div className="absolute inset-0 animate-pulse bg-neutral-800" />
|
||||
|
||||
@@ -321,8 +321,8 @@ export default function ReelsAcademyCard({
|
||||
src={buildStorageUrl(item.profile_image)}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -67,9 +67,8 @@ function ProfileAvatar({
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
width={280}
|
||||
height={280}
|
||||
unoptimized
|
||||
width={sizePxMap[size]}
|
||||
height={sizePxMap[size]}
|
||||
className={cn(
|
||||
"profile-avatar aspect-square object-cover shrink-0",
|
||||
sizeMap[size],
|
||||
|
||||
@@ -40,7 +40,6 @@ const ServiceItem: React.FC<ServiceItemProps> = ({ service, onDelete }) => {
|
||||
height={50}
|
||||
src={buildStorageUrl(image) || image}
|
||||
alt={title}
|
||||
unoptimized
|
||||
className="min-w-[50px] min-h-[50px] rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -43,8 +43,8 @@ export default function AppearanceColorSelect({
|
||||
src={selected.src}
|
||||
alt={selected.label}
|
||||
fill
|
||||
sizes="36px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -93,8 +93,8 @@ export default function AppearanceColorSelect({
|
||||
src={opt.src}
|
||||
alt={opt.label}
|
||||
fill
|
||||
sizes="56px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-neutral-500">{opt.label}</span>
|
||||
|
||||
@@ -222,9 +222,9 @@ function MainModelCard({
|
||||
}
|
||||
alt={user_name}
|
||||
fill
|
||||
sizes="72px"
|
||||
className="aspect-square rounded-2xl object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</StoryRing>
|
||||
|
||||
@@ -120,7 +120,6 @@ const SwipeImageSlider: React.FC<SwipeImageSliderProps> = ({
|
||||
loading={idx === 0 ? "eager" : "lazy"}
|
||||
className="min-h-[30vh] w-full rounded-2xl object-contain shadow-lg transition-opacity duration-300"
|
||||
onLoadingComplete={() => setImageLoading?.(false)}
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -143,7 +143,6 @@ function ModelContentPosts({
|
||||
height={300}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105 group-active:scale-100"
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
) : poster ? (
|
||||
<Image
|
||||
@@ -157,7 +156,6 @@ function ModelContentPosts({
|
||||
height={300}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105 group-active:scale-100"
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
|
||||
@@ -111,8 +111,8 @@ export default function ProfileUserListModal({
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -63,8 +63,8 @@ function LinkListItem({
|
||||
src={imageSrc}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<BoldIcon name={fallbackIcon} size={20} className="dark:invert" />
|
||||
|
||||
@@ -153,11 +153,11 @@ export default function ReelsImageCarousel({
|
||||
src={mediaFiles[0].src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-contain select-none"
|
||||
priority={priority}
|
||||
draggable={false}
|
||||
onLoad={() => onLoadingComplete?.()}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -182,6 +182,7 @@ export default function ReelsImageCarousel({
|
||||
src={file.src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-contain select-none"
|
||||
priority={priority && Math.abs(idx - index) <= 1}
|
||||
loading={
|
||||
@@ -191,7 +192,6 @@ export default function ReelsImageCarousel({
|
||||
onLoad={() => {
|
||||
if (idx === index) onLoadingComplete?.();
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -59,8 +59,8 @@ function ReelsProfileFollowButton({
|
||||
src={profileImage ? buildStorageUrl(profileImage) : "/images/fake-avatar.png"}
|
||||
alt={userName}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="rounded-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -130,8 +130,8 @@ export default function ReelsPostAnalyticsModal({
|
||||
src={previewSrc}
|
||||
alt=""
|
||||
fill
|
||||
sizes="112px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -437,8 +437,8 @@ export default function ReelsPostCard({
|
||||
src={videoPoster}
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
priority={isActive || preloadPriority === "nearby"}
|
||||
/>
|
||||
)}
|
||||
@@ -611,8 +611,8 @@ export default function ReelsPostCard({
|
||||
}
|
||||
alt={user_name}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="rounded-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</StoryRing>
|
||||
|
||||
@@ -137,8 +137,8 @@ export default function SendPostModal({
|
||||
src={preview}
|
||||
alt=""
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -181,8 +181,8 @@ export default function SendPostModal({
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -114,8 +114,8 @@ export default function TagUsersPicker({
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="28px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -178,8 +178,8 @@ export default function TagUsersPicker({
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,6 @@ function SpecialistCard({
|
||||
loading="lazy"
|
||||
className="object-cover"
|
||||
sizes="(min-width: 1024px) 220px, (min-width: 640px) 30vw, 78vw"
|
||||
unoptimized
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -126,7 +125,6 @@ function SpecialistCard({
|
||||
loading="lazy"
|
||||
className="object-cover"
|
||||
sizes="56px"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
|
||||
@@ -82,7 +82,6 @@ function SpecialistCard({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "280px" : "120px"}
|
||||
unoptimized
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -129,7 +128,6 @@ function SpecialistCard({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "64px" : "36px"}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
|
||||
@@ -149,7 +149,6 @@ export default function ActivityPostGrid({ endpoint }: Props) {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -58,8 +58,8 @@ export default function NotificationRow({
|
||||
src={avatarSrc}
|
||||
alt={actor?.user_name || t("common.user")}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
|
||||
@@ -40,8 +40,8 @@ export default function VisitorRow({ visitor }: Props) {
|
||||
src={avatarSrc}
|
||||
alt={visitor.user_name || t("common.user")}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ export default function ProductImageLightbox({
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="100vw"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -125,8 +125,8 @@ export default function SendProductModal({
|
||||
src={IMAGE_BASE_URL + preview}
|
||||
alt=""
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -169,8 +169,8 @@ export default function SendProductModal({
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -114,8 +114,8 @@ export default function ShopProductCommentsModal({
|
||||
src={IMAGE_BASE_URL + c.creator.profile_image}
|
||||
alt=""
|
||||
fill
|
||||
sizes="36px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,6 @@ export default function ShopProductListRow({ listing, shopId }: ShopProductListR
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="80px"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
{discountPercent != null && (
|
||||
|
||||
@@ -252,7 +252,6 @@ export default function ShopReelsView({
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="100vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -144,8 +144,8 @@ export default function SendStoryModal({
|
||||
src={preview}
|
||||
alt=""
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -185,8 +185,8 @@ export default function SendStoryModal({
|
||||
src={buildStorageUrl(u.profile_image)}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,6 @@ function StoryAvatar({
|
||||
width={STORY_AVATAR_SIZE}
|
||||
height={STORY_AVATAR_SIZE}
|
||||
className="block h-full w-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function StoryMentionPicker({
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
{src ? (
|
||||
<Image src={src} alt="" fill className="object-cover" unoptimized />
|
||||
<Image src={src} alt="" fill sizes="40px" className="object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/api/fetchStories";
|
||||
import { storyFont, STORY_FONT_WEIGHTS } from "@/lib/storyFonts";
|
||||
import { buildPostLinkPath, PostLinkKind } from "@/lib/postLinkCaption";
|
||||
import { prefetchVideoUrl } from "@/lib/videoPreload";
|
||||
|
||||
const LINK_KIND_ICONS: Record<PostLinkKind, string> = {
|
||||
pkg: "teacher",
|
||||
@@ -175,6 +176,25 @@ export default function StoryViewer({
|
||||
setMediaAspect(16 / 9);
|
||||
}, [currentStory?._id]);
|
||||
|
||||
// پیشبارگذاریِ استوریِ بعدی (همون کاربر یا اولین استوریِ کاربرِ بعدی) — عینِ
|
||||
// prefetchPostVideos توی ریلز که ویدیوی نزدیک رو زودتر گرم میکنه؛ اینجا معادلش برای
|
||||
// سواپِ بعدیِ استوری، چون قبلاً هیچ پیشبارگذاریای برای استوری وجود نداشت
|
||||
useEffect(() => {
|
||||
const nextInSameUser = currentUser?.stories[storyIndex + 1];
|
||||
const nextUser = feed[userIndex + 1];
|
||||
const nextStory = nextInSameUser ?? nextUser?.stories[0];
|
||||
if (!nextStory) return;
|
||||
const nextSrc = nextStory.media_path.startsWith("http")
|
||||
? nextStory.media_path
|
||||
: buildStorageUrl(nextStory.media_path);
|
||||
if (nextStory.media_type === "video") {
|
||||
prefetchVideoUrl(nextSrc, "low");
|
||||
} else if (typeof window !== "undefined") {
|
||||
const img = new window.Image();
|
||||
img.src = nextSrc;
|
||||
}
|
||||
}, [currentUser, storyIndex, userIndex, feed]);
|
||||
|
||||
// بوم واقعیِ نمایشدادهشده: contain-fit نسبتِ واقعیِ عکس داخلِ فضای در دسترس — دقیقاً
|
||||
// همون منطقِ StoryOverlayEditor.tsx (که اینبار نسبتها ازش میان) و mediaBox توی
|
||||
// اندروید (StoryViewerModal.tsx)
|
||||
@@ -447,8 +467,8 @@ export default function StoryViewer({
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</StoryRing>
|
||||
@@ -514,9 +534,9 @@ export default function StoryViewer({
|
||||
src={mediaSrc}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 512px"
|
||||
className="object-contain"
|
||||
priority
|
||||
unoptimized
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
@@ -853,8 +873,8 @@ export default function StoryViewer({
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
Reference in New Issue
Block a user