Apply Sanity crop and hotspot to images

Fetch the full image objects instead of only the asset URL so the URL
builder can apply the crop and hotspot set in the Studio, crop the Open
Graph image around the hotspot, and add sizes to full-width blog images.

Closes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
leanderandClaude Opus 5.5 committed 2026-10-30 23:42:21 +01:00
1 parent f647dd869d
commit 1a18096cf6
4 files changed
+27 -40

No files matched your search

+13 -10
View File
@@ -2,7 +2,7 @@ import Footer from "@/components/page-sections/footer"
import { generateSeoMetadata } from "@/components/seo" import { generateSeoMetadata } from "@/components/seo"
import Back from "@/components/ui/back" import Back from "@/components/ui/back"
import { urlFor } from "@/lib/imageUrlBuilder" import { type SanityImage, urlFor } from "@/lib/imageUrlBuilder"
import { getBlogPost } from "@/lib/sanity" import { getBlogPost } from "@/lib/sanity"
import { PortableText, PortableTextComponents } from "@portabletext/react" import { PortableText, PortableTextComponents } from "@portabletext/react"
@@ -20,11 +20,7 @@ interface BlogPageParams {
}> }>
} }
interface ImageValue { type ImageValue = Partial<SanityImage> & {
asset?: {
_id: string
url: string
}
alt?: string alt?: string
caption?: string caption?: string
} }
@@ -67,7 +63,10 @@ export async function generateMetadata({
path: `/blog/${slug}`, path: `/blog/${slug}`,
title: data.title, title: data.title,
description: data.excerpt, description: data.excerpt,
imageURL: data.mainImage?.asset?.url, // Cropped around the hotspot to the size link previews expect
imageURL: data.mainImage?.asset
? urlFor(data.mainImage).width(1200).height(630).url()
: undefined,
author: data.author?.name, author: data.author?.name,
publishedTime: data._createdAt, publishedTime: data._createdAt,
}) })
@@ -80,10 +79,13 @@ const components: PortableTextComponents = {
return ( return (
<div className={styles.imageContainer}> <div className={styles.imageContainer}>
<Image <Image
src={urlFor(value).url()} // Only a width, so the image keeps its shape and just the crop
// applies. fit("max") avoids upscaling small images
src={urlFor(value).width(1200).fit("max").url()}
alt={value.alt || "Blog image"} alt={value.alt || "Blog image"}
width={800} width={800}
height={400} height={400}
sizes="(max-width: 640px) 100vw, 640px"
style={{ width: "100%", height: "auto" }} style={{ width: "100%", height: "auto" }}
/> />
{value.caption && ( {value.caption && (
@@ -144,10 +146,11 @@ const BlogPost = async ({ params }: BlogPageParams) => {
<header className={styles.header}> <header className={styles.header}>
{data.mainImage && ( {data.mainImage && (
<Image <Image
src={urlFor(data.mainImage).url()} src={urlFor(data.mainImage).width(1600).fit("max").url()}
alt={data.title} alt={data.title}
width={800} width={800}
height={400} height={400}
sizes="(max-width: 850px) 100vw, 850px"
className={styles.headerImage} className={styles.headerImage}
/> />
)} )}
@@ -158,7 +161,7 @@ const BlogPost = async ({ params }: BlogPageParams) => {
<Image <Image
className="circular" className="circular"
alt={data.author.name} alt={data.author.name}
src={urlFor(data.author.image).width(50).height(50).url()} src={urlFor(data.author.image).width(100).height(100).url()}
width={50} width={50}
height={50} height={50}
/> />
+5 -12
View File
@@ -1,4 +1,4 @@
import { urlFor } from "@/lib/imageUrlBuilder" import { type SanityImage, urlFor } from "@/lib/imageUrlBuilder"
import { getBoardMembers } from "@/lib/sanity" import { getBoardMembers } from "@/lib/sanity"
import { getLocale, getTranslations } from "next-intl/server" import { getLocale, getTranslations } from "next-intl/server"
@@ -14,12 +14,7 @@ interface BoardMember {
no: string no: string
en: string en: string
} }
image?: { image: SanityImage | null
asset?: {
_id: string
url: string
}
}
bio?: { bio?: {
no: string no: string
en: string en: string
@@ -44,11 +39,9 @@ const BoardMembers = async () => {
> >
<Image <Image
src={ src={
member.image?.asset?.url member.image?.asset
? urlFor(member.image.asset.url) ? // Cropped to a square around the hotspot
.width(512) urlFor(member.image).width(512).height(512).url()
.height(512)
.url()
: "/person.png" : "/person.png"
} }
width={256} width={256}
+6
View File
@@ -1,5 +1,6 @@
import { import {
createImageUrlBuilder, createImageUrlBuilder,
type SanityImageObject,
type SanityImageSource, type SanityImageSource,
} from "@sanity/image-url" } from "@sanity/image-url"
@@ -7,6 +8,11 @@ import { sanityClient } from "./sanity"
const builder = createImageUrlBuilder(sanityClient) const builder = createImageUrlBuilder(sanityClient)
// An image field as stored in Sanity: an asset reference plus the crop and
// hotspot chosen in the Studio. Pass the whole object to urlFor, not the
// asset URL, or the crop and hotspot are ignored
export type SanityImage = SanityImageObject
export function urlFor(source: SanityImageSource) { export function urlFor(source: SanityImageSource) {
return builder.image(source) return builder.image(source)
} }
+3 -18
View File
@@ -17,19 +17,9 @@ export async function getBlogPost(slug: string) {
title, title,
author-> { author-> {
name, name,
image { image
asset-> {
_id,
url
}
}
},
mainImage {
asset-> {
_id,
url
}
}, },
mainImage,
excerpt, excerpt,
body body
} }
@@ -119,12 +109,7 @@ export async function getBoardMembers() {
no, no,
en en
}, },
image { image,
asset->{
_id,
url
}
},
bio { bio {
no, no,
en en