diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c9ef1f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# ── Sanity CMS ─────────────────────────────────────────────────────────────── +# Get these values from https://www.sanity.io/manage after creating a project. +# Without SANITY_PROJECT_ID the site falls back to the local markdown files +# in src/content/news/. + +# Your Sanity project ID (required to enable Sanity) +SANITY_PROJECT_ID= + +# Dataset name — "production" is the default created by `sanity init` +SANITY_DATASET=production + +# Read token — only needed for draft/preview content. +# Leave blank to serve published content without authentication. +SANITY_API_TOKEN= diff --git a/package-lock.json b/package-lock.json index 4adf602..05da6e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", "@neondatabase/serverless": "^0.10.4", + "@portabletext/to-html": "^5.0.2", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.3", @@ -40,6 +41,7 @@ "@radix-ui/react-toggle": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", + "@sanity/client": "^7.22.1", "@tanstack/react-query": "^5.60.5", "astro": "^5.16.6", "class-variance-authority": "^0.7.1", @@ -2111,6 +2113,40 @@ "node": ">=14" } }, + "node_modules/@portabletext/to-html": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@portabletext/to-html/-/to-html-5.0.2.tgz", + "integrity": "sha512-w59PcErj5JXUCv9tbV2npqJmcnORTAftCMLp0vc9FnWrXL3C9qYvuB2MQbdHsZEOesF3VmwqUsYUgjm7PX4JTw==", + "license": "MIT", + "dependencies": { + "@portabletext/toolkit": "^5.0.2", + "@portabletext/types": "^4.0.2" + }, + "engines": { + "node": ">=20.19 <22 || >=22.12" + } + }, + "node_modules/@portabletext/toolkit": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@portabletext/toolkit/-/toolkit-5.0.2.tgz", + "integrity": "sha512-Njc1LE1PMJkTx/wEPqZ6sOWGgFgX2B47fxpOQ/Ia4ByhsZoA5Sq8dNvvV5F052j/xE8TbOLiBEjS848FkKADDQ==", + "license": "MIT", + "dependencies": { + "@portabletext/types": "^4.0.2" + }, + "engines": { + "node": ">=20.19 <22 || >=22.12" + } + }, + "node_modules/@portabletext/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@portabletext/types/-/types-4.0.2.tgz", + "integrity": "sha512-djfIGU9n6DRrunlvj2nIDAp17URo/nA4jSXGvf+Gupx8NLLy9fmJBZ3GL8yhqn9lSVc+cKCharjOa3aOBnWbRw==", + "license": "MIT", + "engines": { + "node": ">=20.19 <22 || >=22.12" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3736,6 +3772,33 @@ "win32" ] }, + "node_modules/@sanity/client": { + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@sanity/client/-/client-7.22.1.tgz", + "integrity": "sha512-DrQqLAes7W9Ek2iKgTZ+ffY1v3DURJ6//iXJZMEQSUeRbxls2ifRTdV1gRS26v8nMkL0OCH+WCoUUseD4vhuQQ==", + "license": "MIT", + "dependencies": { + "@sanity/eventsource": "^5.0.2", + "get-it": "^8.7.2", + "nanoid": "^3.3.11", + "rxjs": "^7.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sanity/eventsource": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sanity/eventsource/-/eventsource-5.0.2.tgz", + "integrity": "sha512-/B9PMkUvAlUrpRq0y+NzXgRv5lYCLxZNsBJD2WXVnqZYOfByL9oQBV7KiTaARuObp5hcQYuPfOAVjgXe3hrixA==", + "license": "MIT", + "dependencies": { + "@types/event-source-polyfill": "1.0.5", + "@types/eventsource": "1.1.15", + "event-source-polyfill": "1.0.31", + "eventsource": "2.0.2" + } + }, "node_modules/@shikijs/core": { "version": "3.20.0", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.20.0.tgz", @@ -4269,6 +4332,18 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/event-source-polyfill": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.5.tgz", + "integrity": "sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==", + "license": "MIT" + }, + "node_modules/@types/eventsource": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz", + "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==", + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -5810,6 +5885,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-7.0.0.tgz", + "integrity": "sha512-6IvPrADQyyPGLpMnUh6kfKiqy7SrbXbjoUuZ90WMBJKErzv2pCiwlGEXjRX9/54OnTq+XFVnkOnOMzclLI5aEA==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -6389,12 +6479,27 @@ "node": ">= 0.6" } }, + "node_modules/event-source-polyfill": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz", + "integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==", + "license": "MIT" + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -6782,6 +6887,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-it": { + "version": "8.7.3", + "resolved": "https://registry.npmjs.org/get-it/-/get-it-8.7.3.tgz", + "integrity": "sha512-Fcv6n05iKMY/obxWb7wL0T8xOiUN/5ptBlg/Vf2WAvfs175wAV7Qf+UtWKp7Jdneu+KbUe6MIL0js2WBzoAoyg==", + "license": "MIT", + "dependencies": { + "decompress-response": "^7.0.0", + "is-retry-allowed": "^2.2.0", + "through2": "^4.0.2", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -7314,6 +7434,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-wsl": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", @@ -8664,6 +8796,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -9858,6 +10002,20 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -10250,6 +10408,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -10592,6 +10759,15 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -10854,6 +11030,15 @@ "node": ">=0.8" } }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -11015,6 +11200,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tw-animate-css": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz", diff --git a/package.json b/package.json index 48095b6..1c8dac2 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", "@neondatabase/serverless": "^0.10.4", + "@portabletext/to-html": "^5.0.2", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.3", @@ -45,6 +46,7 @@ "@radix-ui/react-toggle": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", + "@sanity/client": "^7.22.1", "@tanstack/react-query": "^5.60.5", "astro": "^5.16.6", "class-variance-authority": "^0.7.1", diff --git a/src/lib/news.ts b/src/lib/news.ts index dd692ff..fc6a054 100644 --- a/src/lib/news.ts +++ b/src/lib/news.ts @@ -1,5 +1,6 @@ import { format } from "date-fns"; import { marked } from "marked"; +import { isSanityConfigured, getSanityPosts, getSanityPost } from "./sanity"; export type NewsArticle = { title: string; @@ -76,3 +77,27 @@ export const formatNewsDate = (date: string): string => { if (Number.isNaN(parsed)) return date; return format(new Date(parsed), "MMM yyyy"); }; + +// ── Async data access (prefers Sanity when configured) ──────────────────────── + +/** + * Returns all articles. Uses Sanity when SANITY_PROJECT_ID is set, + * otherwise falls back to the local markdown files. + */ +export async function getArticles(): Promise { + if (isSanityConfigured()) { + return getSanityPosts(); + } + return newsArticles; +} + +/** + * Returns a single article by slug. Uses Sanity when SANITY_PROJECT_ID is set, + * otherwise falls back to the local markdown files. + */ +export async function getArticle(slug: string): Promise { + if (isSanityConfigured()) { + return getSanityPost(slug); + } + return getNewsArticle(slug); +} diff --git a/src/lib/sanity.ts b/src/lib/sanity.ts new file mode 100644 index 0000000..182ab26 --- /dev/null +++ b/src/lib/sanity.ts @@ -0,0 +1,89 @@ +import { createClient } from '@sanity/client'; +import { toHTML } from '@portabletext/to-html'; +import type { NewsArticle } from './news'; + +// ── Client ──────────────────────────────────────────────────────────────────── + +export const sanityClient = createClient({ + projectId: import.meta.env.SANITY_PROJECT_ID ?? '', + dataset: import.meta.env.SANITY_DATASET ?? 'production', + apiVersion: '2024-01-01', + // useCdn in production; always false during dev so edits show immediately + useCdn: import.meta.env.PROD ?? false, + // Token only needed for draft previews; public published content works without one + token: import.meta.env.SANITY_API_TOKEN, +}); + +export function isSanityConfigured(): boolean { + return Boolean(import.meta.env.SANITY_PROJECT_ID); +} + +// ── GROQ queries ────────────────────────────────────────────────────────────── + +const POST_FIELDS = /* groq */ ` + _id, + title, + "slug": slug.current, + date, + category, + summary, + "image": image.asset->url, + body +`; + +const ALL_POSTS_QUERY = /* groq */ `*[_type == "post"] | order(date desc) { ${POST_FIELDS} }`; +const POST_BY_SLUG_QUERY = /* groq */ `*[_type == "post" && slug.current == $slug][0] { ${POST_FIELDS} }`; + +// ── Raw Sanity shape ────────────────────────────────────────────────────────── + +interface SanityPost { + _id: string; + title: string; + slug: string; + date: string; + category: string; + summary: string; + image?: string | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body?: any[]; +} + +// ── Converters ──────────────────────────────────────────────────────────────── + +function toNewsArticle(post: SanityPost): NewsArticle { + const html = post.body + ? toHTML(post.body, { + components: { + marks: { + link: ({ children, value }) => { + const target = value?.blank ? ' target="_blank" rel="noreferrer"' : ''; + return `${children}`; + }, + }, + }, + }) + : ''; + + return { + title: post.title, + date: post.date ?? '', + summary: post.summary ?? '', + image: post.image ?? undefined, + slug: post.slug, + category: post.category ?? 'News', + body: '', // raw markdown not used when coming from Sanity + html, + }; +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +export async function getSanityPosts(): Promise { + const posts = await sanityClient.fetch(ALL_POSTS_QUERY); + return posts.map(toNewsArticle); +} + +export async function getSanityPost(slug: string): Promise { + const post = await sanityClient.fetch(POST_BY_SLUG_QUERY, { slug }); + return post ? toNewsArticle(post) : undefined; +} diff --git a/src/pages/events/[slug].astro b/src/pages/events/[slug].astro index 836e56c..a2ae9df 100644 --- a/src/pages/events/[slug].astro +++ b/src/pages/events/[slug].astro @@ -3,16 +3,14 @@ import Base from "../../layouts/Base.astro"; import SiteHeader from "../../components/SiteHeader.astro"; import SiteFooter from "../../components/SiteFooter.astro"; import { navigationItems } from "../../lib/navigation"; -import { newsArticles, formatNewsDate } from "../../lib/news"; +import { getArticle, formatNewsDate } from "../../lib/news"; -export function getStaticPaths() { - return newsArticles.map((article) => ({ - params: { slug: article.slug }, - props: { article }, - })); +const { slug } = Astro.params; +const article = await getArticle(slug!); + +if (!article) { + return Astro.redirect("/events", 302); } - -const { article } = Astro.props; --- i + 1) - .filter((page) => page > 1) - .map((page) => ({ params: { page: String(page) } })); -} +import { getArticles } from "../../../lib/news"; +const allArticles = await getArticles(); const currentPage = Number(Astro.params.page ?? "1"); const PER_PAGE = 6; -const totalPages = Math.ceil(newsArticles.length / PER_PAGE); +const totalPages = Math.ceil(allArticles.length / PER_PAGE); const start = (currentPage - 1) * PER_PAGE; -const pagedArticles = newsArticles.slice(start, start + PER_PAGE); +const pagedArticles = allArticles.slice(start, start + PER_PAGE); const pageHref = (page: number) => (page === 1 ? "/events" : `/events/page/${page}`); const pageSeo = { diff --git a/studio/package.json b/studio/package.json new file mode 100644 index 0000000..bf99111 --- /dev/null +++ b/studio/package.json @@ -0,0 +1,15 @@ +{ + "name": "rda-studio", + "private": true, + "version": "1.0.0", + "scripts": { + "dev": "sanity dev", + "build": "sanity build", + "deploy": "sanity deploy" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sanity": "^3.66.0" + } +} diff --git a/studio/sanity.config.ts b/studio/sanity.config.ts new file mode 100644 index 0000000..1bcc61d --- /dev/null +++ b/studio/sanity.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'sanity'; +import { structureTool } from 'sanity/structure'; +import { visionTool } from '@sanity/vision'; +import { schemaTypes } from './schemaTypes'; + +export default defineConfig({ + name: 'rda-v3', + title: 'Highland Group RDA', + + // Fill these in after running `sanity init` or creating a project at sanity.io/manage + projectId: process.env.SANITY_STUDIO_PROJECT_ID ?? '', + dataset: process.env.SANITY_STUDIO_DATASET ?? 'production', + + plugins: [ + structureTool(), + visionTool(), // GROQ query playground — remove in production if desired + ], + + schema: { + types: schemaTypes, + }, +}); diff --git a/studio/schemaTypes/index.ts b/studio/schemaTypes/index.ts new file mode 100644 index 0000000..cefe043 --- /dev/null +++ b/studio/schemaTypes/index.ts @@ -0,0 +1,3 @@ +import { postType } from './postType'; + +export const schemaTypes = [postType]; diff --git a/studio/schemaTypes/postType.ts b/studio/schemaTypes/postType.ts new file mode 100644 index 0000000..406f2c5 --- /dev/null +++ b/studio/schemaTypes/postType.ts @@ -0,0 +1,116 @@ +import { defineField, defineType } from 'sanity'; + +export const postType = defineType({ + name: 'post', + title: 'Post', + type: 'document', + fields: [ + defineField({ + name: 'title', + title: 'Title', + type: 'string', + validation: (r) => r.required(), + }), + defineField({ + name: 'slug', + title: 'Slug', + type: 'slug', + options: { source: 'title', maxLength: 96 }, + validation: (r) => r.required(), + }), + defineField({ + name: 'date', + title: 'Date', + type: 'date', + options: { dateFormat: 'YYYY-MM-DD' }, + validation: (r) => r.required(), + }), + defineField({ + name: 'category', + title: 'Category', + type: 'string', + options: { + list: [ + { title: 'News', value: 'News' }, + { title: 'Events', value: 'Events' }, + { title: 'Fundraising', value: 'Fundraising' }, + ], + }, + initialValue: 'News', + validation: (r) => r.required(), + }), + defineField({ + name: 'summary', + title: 'Summary', + type: 'text', + rows: 3, + description: 'One or two sentences shown in article cards and meta descriptions.', + validation: (r) => r.required().max(300), + }), + defineField({ + name: 'image', + title: 'Cover image', + type: 'image', + options: { hotspot: true }, + fields: [ + defineField({ + name: 'alt', + title: 'Alt text', + type: 'string', + }), + ], + }), + defineField({ + name: 'body', + title: 'Body', + type: 'array', + of: [ + { + type: 'block', + styles: [ + { title: 'Normal', value: 'normal' }, + { title: 'Heading 2', value: 'h2' }, + { title: 'Heading 3', value: 'h3' }, + { title: 'Quote', value: 'blockquote' }, + ], + marks: { + decorators: [ + { title: 'Bold', value: 'strong' }, + { title: 'Italic', value: 'em' }, + ], + annotations: [ + { + name: 'link', + type: 'object', + title: 'Link', + fields: [ + { + name: 'href', + type: 'url', + title: 'URL', + validation: (r) => + r.uri({ allowRelative: true, scheme: ['http', 'https', 'mailto', 'tel'] }), + }, + { + name: 'blank', + type: 'boolean', + title: 'Open in new tab', + initialValue: false, + }, + ], + }, + ], + }, + }, + { type: 'image', options: { hotspot: true } }, + ], + }), + ], + preview: { + select: { + title: 'title', + subtitle: 'date', + media: 'image', + }, + }, +});