This commit is contained in:
2025-06-04 14:44:37 +02:00
commit e0ee3498c7
119 changed files with 20681 additions and 0 deletions

30
src/sanity/client.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createClient, type QueryParams } from 'next-sanity'
import { apiVersion, dataset, projectId } from './env'
const isDevelopment = process.env.NODE_ENV === 'development'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: isDevelopment ? false : true,
})
export async function sanityFetch<const QueryString extends string>({
query,
params = {},
revalidate = 60,
tags = [],
}: {
query: QueryString
params?: QueryParams
revalidate?: number | false
tags?: string[]
}) {
return client.fetch(query, params, {
next: {
revalidate: isDevelopment || tags.length ? false : revalidate,
tags,
},
})
}

20
src/sanity/env.ts Normal file
View File

@@ -0,0 +1,20 @@
export const apiVersion =
process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2024-07-25'
export const dataset = assertValue(
process.env.NEXT_PUBLIC_SANITY_DATASET,
'Missing environment variable: NEXT_PUBLIC_SANITY_DATASET',
)
export const projectId = assertValue(
process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
'Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID',
)
function assertValue<T>(v: T | undefined, errorMessage: string): T {
if (v === undefined) {
throw new Error(errorMessage)
}
return v
}

9
src/sanity/image.ts Normal file
View File

@@ -0,0 +1,9 @@
import createImageUrlBuilder from '@sanity/image-url'
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
import { dataset, projectId } from './env'
const builder = createImageUrlBuilder({ projectId, dataset })
export function image(source: SanityImageSource) {
return builder.image(source).auto('format')
}

131
src/sanity/queries.ts Normal file
View File

@@ -0,0 +1,131 @@
import { defineQuery } from 'next-sanity'
import { sanityFetch } from './client'
const TOTAL_POSTS_QUERY = defineQuery(/* groq */ `count(*[
_type == "post"
&& defined(slug.current)
&& (isFeatured != true || defined($category))
&& select(defined($category) => $category in categories[]->slug.current, true)
])`)
export async function getPostsCount(category?: string) {
return await sanityFetch({
query: TOTAL_POSTS_QUERY,
params: { category: category ?? null },
})
}
const POSTS_QUERY = defineQuery(/* groq */ `*[
_type == "post"
&& defined(slug.current)
&& (isFeatured != true || defined($category))
&& select(defined($category) => $category in categories[]->slug.current, true)
]|order(publishedAt desc)[$startIndex...$endIndex]{
title,
"slug": slug.current,
publishedAt,
excerpt,
author->{
name,
image,
},
}`)
export async function getPosts(
startIndex: number,
endIndex: number,
category?: string,
) {
return await sanityFetch({
query: POSTS_QUERY,
params: {
startIndex,
endIndex,
category: category ?? null,
},
})
}
const FEATURED_POSTS_QUERY = defineQuery(/* groq */ `*[
_type == "post"
&& isFeatured == true
&& defined(slug.current)
]|order(publishedAt desc)[0...$quantity]{
title,
"slug": slug.current,
publishedAt,
mainImage,
excerpt,
author->{
name,
image,
},
}`)
export async function getFeaturedPosts(quantity: number) {
return await sanityFetch({
query: FEATURED_POSTS_QUERY,
params: { quantity },
})
}
const FEED_POSTS_QUERY = defineQuery(/* groq */ `*[
_type == "post"
&& defined(slug.current)
]|order(isFeatured, publishedAt desc){
title,
"slug": slug.current,
publishedAt,
mainImage,
excerpt,
author->{
name,
},
}`)
export async function getPostsForFeed() {
return await sanityFetch({
query: FEED_POSTS_QUERY,
})
}
const POST_QUERY = defineQuery(/* groq */ `*[
_type == "post"
&& slug.current == $slug
][0]{
publishedAt,
title,
mainImage,
excerpt,
body,
author->{
name,
image,
},
categories[]->{
title,
"slug": slug.current,
}
}
`)
export async function getPost(slug: string) {
return await sanityFetch({
query: POST_QUERY,
params: { slug },
})
}
const CATEGORIES_QUERY = defineQuery(/* groq */ `*[
_type == "category"
&& count(*[_type == "post" && defined(slug.current) && ^._id in categories[]._ref]) > 0
]|order(title asc){
title,
"slug": slug.current,
}`)
export async function getCategories() {
return await sanityFetch({
query: CATEGORIES_QUERY,
})
}

10
src/sanity/schema.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { SchemaTypeDefinition } from 'sanity'
import { authorType } from './types/author'
import { blockContentType } from './types/block-content'
import { categoryType } from './types/category'
import { postType } from './types/post'
export const schema: { types: SchemaTypeDefinition[] } = {
types: [blockContentType, categoryType, postType, authorType],
}

458
src/sanity/types.ts Normal file
View File

@@ -0,0 +1,458 @@
/**
* ---------------------------------------------------------------------------------
* This file has been generated by Sanity TypeGen.
* Command: `sanity typegen generate`
*
* Any modifications made directly to this file will be overwritten the next time
* the TypeScript definitions are generated. Please make changes to the Sanity
* schema definitions and/or GROQ queries if you need to update these types.
*
* For more information on how to use Sanity TypeGen, visit the official documentation:
* https://www.sanity.io/docs/sanity-typegen
* ---------------------------------------------------------------------------------
*/
// Source: schema.json
export type SanityImagePaletteSwatch = {
_type: 'sanity.imagePaletteSwatch'
background?: string
foreground?: string
population?: number
title?: string
}
export type SanityImagePalette = {
_type: 'sanity.imagePalette'
darkMuted?: SanityImagePaletteSwatch
lightVibrant?: SanityImagePaletteSwatch
darkVibrant?: SanityImagePaletteSwatch
vibrant?: SanityImagePaletteSwatch
dominant?: SanityImagePaletteSwatch
lightMuted?: SanityImagePaletteSwatch
muted?: SanityImagePaletteSwatch
}
export type SanityImageDimensions = {
_type: 'sanity.imageDimensions'
height?: number
width?: number
aspectRatio?: number
}
export type SanityFileAsset = {
_id: string
_type: 'sanity.fileAsset'
_createdAt: string
_updatedAt: string
_rev: string
originalFilename?: string
label?: string
title?: string
description?: string
altText?: string
sha1hash?: string
extension?: string
mimeType?: string
size?: number
assetId?: string
uploadId?: string
path?: string
url?: string
source?: SanityAssetSourceData
}
export type Geopoint = {
_type: 'geopoint'
lat?: number
lng?: number
alt?: number
}
export type Post = {
_id: string
_type: 'post'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
slug?: Slug
publishedAt?: string
isFeatured?: boolean
author?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'author'
}
mainImage?: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
}
categories?: Array<{
_ref: string
_type: 'reference'
_weak?: boolean
_key: string
[internalGroqTypeReferenceTo]?: 'category'
}>
excerpt?: string
body?: Array<
| {
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal' | 'h2' | 'h3' | 'blockquote'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}
| {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
_key: string
}
>
}
export type Author = {
_id: string
_type: 'author'
_createdAt: string
_updatedAt: string
_rev: string
name?: string
slug?: Slug
image?: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
}
}
export type Category = {
_id: string
_type: 'category'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
slug?: Slug
}
export type Slug = {
_type: 'slug'
current?: string
source?: string
}
export type BlockContent = Array<
| {
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal' | 'h2' | 'h3' | 'blockquote'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}
| {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
_key: string
}
>
export type SanityImageCrop = {
_type: 'sanity.imageCrop'
top?: number
bottom?: number
left?: number
right?: number
}
export type SanityImageHotspot = {
_type: 'sanity.imageHotspot'
x?: number
y?: number
height?: number
width?: number
}
export type SanityImageAsset = {
_id: string
_type: 'sanity.imageAsset'
_createdAt: string
_updatedAt: string
_rev: string
originalFilename?: string
label?: string
title?: string
description?: string
altText?: string
sha1hash?: string
extension?: string
mimeType?: string
size?: number
assetId?: string
uploadId?: string
path?: string
url?: string
metadata?: SanityImageMetadata
source?: SanityAssetSourceData
}
export type SanityAssetSourceData = {
_type: 'sanity.assetSourceData'
name?: string
id?: string
url?: string
}
export type SanityImageMetadata = {
_type: 'sanity.imageMetadata'
location?: Geopoint
dimensions?: SanityImageDimensions
palette?: SanityImagePalette
lqip?: string
blurHash?: string
hasAlpha?: boolean
isOpaque?: boolean
}
export type AllSanitySchemaTypes =
| SanityImagePaletteSwatch
| SanityImagePalette
| SanityImageDimensions
| SanityFileAsset
| Geopoint
| Post
| Author
| Category
| Slug
| BlockContent
| SanityImageCrop
| SanityImageHotspot
| SanityImageAsset
| SanityAssetSourceData
| SanityImageMetadata
export declare const internalGroqTypeReferenceTo: unique symbol
// Source: ./src/sanity/queries.ts
// Variable: TOTAL_POSTS_QUERY
// Query: count(*[ _type == "post" && defined(slug.current) && (isFeatured != true || defined($category)) && select(defined($category) => $category in categories[]->slug.current, true)])
export type TOTAL_POSTS_QUERYResult = number
// Variable: POSTS_QUERY
// Query: *[ _type == "post" && defined(slug.current) && (isFeatured != true || defined($category)) && select(defined($category) => $category in categories[]->slug.current, true)]|order(publishedAt desc)[$startIndex...$endIndex]{ title, "slug": slug.current, publishedAt, excerpt, author->{ name, image, },}
export type POSTS_QUERYResult = Array<{
title: string | null
slug: string | null
publishedAt: string | null
excerpt: string | null
author: {
name: string | null
image: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
} | null
} | null
}>
// Variable: FEATURED_POSTS_QUERY
// Query: *[ _type == "post" && isFeatured == true && defined(slug.current)]|order(publishedAt desc)[0...$quantity]{ title, "slug": slug.current, publishedAt, mainImage, excerpt, author->{ name, image, },}
export type FEATURED_POSTS_QUERYResult = Array<{
title: string | null
slug: string | null
publishedAt: string | null
mainImage: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
} | null
excerpt: string | null
author: {
name: string | null
image: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
} | null
} | null
}>
// Variable: FEED_POSTS_QUERY
// Query: *[ _type == "post" && defined(slug.current)]|order(isFeatured, publishedAt desc){ title, "slug": slug.current, publishedAt, mainImage, excerpt, author->{ name, },}
export type FEED_POSTS_QUERYResult = Array<{
title: string | null
slug: string | null
publishedAt: string | null
mainImage: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
} | null
excerpt: string | null
author: {
name: string | null
} | null
}>
// Variable: POST_QUERY
// Query: *[ _type == "post" && slug.current == $slug][0]{ publishedAt, title, mainImage, excerpt, body, author->{ name, image, }, categories[]->{ title, "slug": slug.current, }}
export type POST_QUERYResult = {
publishedAt: string | null
title: string | null
mainImage: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
} | null
excerpt: string | null
body: Array<
| {
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'blockquote' | 'h2' | 'h3' | 'normal'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}
| {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
_key: string
}
> | null
author: {
name: string | null
image: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
} | null
} | null
categories: Array<{
title: string | null
slug: string | null
}> | null
} | null
// Variable: CATEGORIES_QUERY
// Query: *[ _type == "category" && count(*[_type == "post" && defined(slug.current) && ^._id in categories[]._ref]) > 0]|order(title asc){ title, "slug": slug.current,}
export type CATEGORIES_QUERYResult = Array<{
title: string | null
slug: string | null
}>
// Query TypeMap
import '@sanity/client'
declare module '@sanity/client' {
interface SanityQueries {
'count(*[\n _type == "post"\n && defined(slug.current)\n && (isFeatured != true || defined($category))\n && select(defined($category) => $category in categories[]->slug.current, true)\n])': TOTAL_POSTS_QUERYResult
'*[\n _type == "post"\n && defined(slug.current)\n && (isFeatured != true || defined($category))\n && select(defined($category) => $category in categories[]->slug.current, true)\n]|order(publishedAt desc)[$startIndex...$endIndex]{\n title,\n "slug": slug.current,\n publishedAt,\n excerpt,\n author->{\n name,\n image,\n },\n}': POSTS_QUERYResult
'*[\n _type == "post"\n && isFeatured == true\n && defined(slug.current)\n]|order(publishedAt desc)[0...$quantity]{\n title,\n "slug": slug.current,\n publishedAt,\n mainImage,\n excerpt,\n author->{\n name,\n image,\n },\n}': FEATURED_POSTS_QUERYResult
'*[\n _type == "post"\n && defined(slug.current)\n]|order(isFeatured, publishedAt desc){\n title,\n "slug": slug.current,\n publishedAt,\n mainImage,\n excerpt,\n author->{\n name,\n },\n}': FEED_POSTS_QUERYResult
'*[\n _type == "post"\n && slug.current == $slug\n][0]{\n publishedAt,\n title,\n mainImage,\n excerpt,\n body,\n author->{\n name,\n image,\n },\n categories[]->{\n title,\n "slug": slug.current,\n }\n}\n': POST_QUERYResult
'*[\n _type == "category"\n && count(*[_type == "post" && defined(slug.current) && ^._id in categories[]._ref]) > 0\n]|order(title asc){\n title,\n "slug": slug.current,\n}': CATEGORIES_QUERYResult
}
}

View File

@@ -0,0 +1,36 @@
import { UserIcon } from '@heroicons/react/16/solid'
import { defineField, defineType } from 'sanity'
export const authorType = defineType({
name: 'author',
title: 'Author',
type: 'document',
icon: UserIcon,
fields: [
defineField({
name: 'name',
type: 'string',
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'name',
maxLength: 96,
},
}),
defineField({
name: 'image',
type: 'image',
options: {
hotspot: true,
},
}),
],
preview: {
select: {
title: 'name',
media: 'image',
},
},
})

View File

@@ -0,0 +1,70 @@
import { ImageIcon } from '@sanity/icons'
import { defineArrayMember, defineType } from 'sanity'
export const blockContentType = defineType({
title: 'Block Content',
name: 'blockContent',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
{ title: 'Quote', value: 'blockquote' },
],
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
],
annotations: [
{
title: 'URL',
name: 'link',
type: 'object',
fields: [
{
title: 'URL',
name: 'href',
type: 'url',
},
],
},
],
},
}),
defineArrayMember({
title: 'Separator',
name: 'separator',
type: 'object',
fields: [
{
name: 'style',
title: 'Style',
type: 'string',
options: {
list: [
{ title: 'Line', value: 'line' },
{ title: 'Space', value: 'space' },
],
},
},
],
}),
defineArrayMember({
type: 'image',
icon: ImageIcon,
options: { hotspot: true },
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
},
],
}),
],
})

View File

@@ -0,0 +1,21 @@
import { TagIcon } from '@heroicons/react/16/solid'
import { defineField, defineType } from 'sanity'
export const categoryType = defineType({
name: 'category',
type: 'document',
icon: TagIcon,
fields: [
defineField({
name: 'title',
type: 'string',
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'title',
},
}),
],
})

116
src/sanity/types/post.ts Normal file
View File

@@ -0,0 +1,116 @@
import { DocumentIcon } from '@heroicons/react/16/solid'
import { groq } from 'next-sanity'
import { defineField, defineType } from 'sanity'
import { apiVersion } from '../env'
export const postType = defineType({
name: 'post',
title: 'Post',
type: 'document',
icon: DocumentIcon,
fields: [
defineField({
name: 'title',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'title',
},
validation: (Rule) =>
Rule.required().error('A slug is required for the post URL.'),
}),
defineField({
name: 'publishedAt',
type: 'datetime',
validation: (Rule) =>
Rule.required().error(
'A publication date is required for ordering posts.',
),
}),
defineField({
name: 'isFeatured',
type: 'boolean',
initialValue: false,
validation: (Rule) =>
Rule.custom(async (isFeatured, { getClient }) => {
if (isFeatured !== true) {
return true
}
let featuredPosts = await getClient({ apiVersion })
.withConfig({ perspective: 'previewDrafts' })
.fetch<number>(
groq`count(*[_type == 'post' && isFeatured == true])`,
)
return featuredPosts > 3
? 'Only 3 posts can be featured at a time.'
: true
}),
}),
defineField({
name: 'author',
type: 'reference',
to: { type: 'author' },
}),
defineField({
name: 'mainImage',
type: 'image',
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text',
},
],
}),
defineField({
name: 'categories',
type: 'array',
of: [{ type: 'reference', to: { type: 'category' } }],
}),
defineField({
name: 'excerpt',
type: 'text',
rows: 3,
}),
defineField({
name: 'body',
type: 'blockContent',
}),
],
preview: {
select: {
title: 'title',
media: 'mainImage',
author: 'author.name',
isFeatured: 'isFeatured',
},
prepare({ title, author, media, isFeatured }) {
return {
title,
subtitle: [isFeatured && 'Featured', author && `By ${author}`]
.filter(Boolean)
.join(' | '),
media,
}
},
},
orderings: [
{
name: 'isFeaturedAndPublishedAtDesc',
title: 'Featured & Latest Published',
by: [
{ field: 'isFeatured', direction: 'desc' },
{ field: 'publishedAt', direction: 'desc' },
],
},
],
})