diff --git a/.env.example b/.env.example index bac39f9..51609fd 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,6 @@ NEXT_PUBLIC_HCAPTCHA_SITE_KEY= HCAPTCHA_SECRET= NEXT_PUBLIC_SANITY_PROJECT_ID= -NEXT_PUBLIC_SANITY_DATASET= \ No newline at end of file +SANITY_AUTH_TOKEN= +NEXT_PUBLIC_SANITY_DATASET= +SANITY_WEBHOOK_SECRET= \ No newline at end of file diff --git a/app/(root)/blog/[slug]/_styles/prism-twilight.css b/app/(root)/blog/[slug]/_styles/prism-twilight.css new file mode 100644 index 0000000..650904b --- /dev/null +++ b/app/(root)/blog/[slug]/_styles/prism-twilight.css @@ -0,0 +1,123 @@ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + +code[class*="language-"], +pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; + font-size: 1em; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: 0.5em 0; + overflow: auto; + border-radius: 0.3em; +} + +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #272822; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: 0.1em; + border-radius: 0.3em; + white-space: normal; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #8292a2; +} + +.token.punctuation { + color: #f8f8f2; +} + +.token.namespace { + opacity: 0.7; +} + +.token.property, +.token.tag, +.token.constant, +.token.symbol, +.token.deleted { + color: #f92672; +} + +.token.boolean, +.token.number { + color: #ae81ff; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #a6e22e; +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string, +.token.variable { + color: #f8f8f2; +} + +.token.atrule, +.token.attr-value, +.token.function, +.token.class-name { + color: #e6db74; +} + +.token.keyword { + color: #66d9ef; +} + +.token.regex, +.token.important { + color: #fd971f; +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} diff --git a/app/(root)/blog/[slug]/_styles/prism.twilight.min.css b/app/(root)/blog/[slug]/_styles/prism.twilight.min.css new file mode 100644 index 0000000..6771ee8 --- /dev/null +++ b/app/(root)/blog/[slug]/_styles/prism.twilight.min.css @@ -0,0 +1,98 @@ +code[class*="language-"], +pre[class*="language-"] { + color: #f8f8f2; + background: 0 0; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; + font-size: 1em; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} +pre[class*="language-"] { + padding: 1em; + margin: 0.5em 0; + overflow: auto; + border-radius: 0.3em; +} +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #272822; +} +:not(pre) > code[class*="language-"] { + padding: 0.1em; + border-radius: 0.3em; + white-space: normal; +} +.token.cdata, +.token.comment, +.token.doctype, +.token.prolog { + color: #8292a2; +} +.token.punctuation { + color: #f8f8f2; +} +.token.namespace { + opacity: 0.7; +} +.token.constant, +.token.deleted, +.token.property, +.token.symbol, +.token.tag { + color: #f92672; +} +.token.boolean, +.token.number { + color: #ae81ff; +} +.token.attr-name, +.token.builtin, +.token.char, +.token.inserted, +.token.selector, +.token.string { + color: #a6e22e; +} +.language-css .token.string, +.style .token.string, +.token.entity, +.token.operator, +.token.url, +.token.variable { + color: #f8f8f2; +} +.token.atrule, +.token.attr-value, +.token.class-name, +.token.function { + color: #e6db74; +} +.token.keyword { + color: #66d9ef; +} +.token.important, +.token.regex { + color: #fd971f; +} +.token.bold, +.token.important { + font-weight: 700; +} +.token.italic { + font-style: italic; +} +.token.entity { + cursor: help; +} diff --git a/app/(root)/blog/[slug]/page.jsx b/app/(root)/blog/[slug]/page.jsx new file mode 100644 index 0000000..e2d8e84 --- /dev/null +++ b/app/(root)/blog/[slug]/page.jsx @@ -0,0 +1,205 @@ +import { client, urlFor } from "@/sanity/lib/client"; +import { PortableText } from "@portabletext/react"; +import Image from "next/image"; +import Link from "next/link"; +import { ArrowLeft, Rss } from "lucide-react"; +import { notFound } from "next/navigation"; +import { format } from "date-fns"; +import PrismLoader from "@/components/PrismLoader"; +import Prism from "prismjs"; +import "prismjs/components/prism-javascript"; +import "prismjs/components/prism-python"; +import "prismjs/components/prism-php"; +import "prismjs/components/prism-bash"; +import "prismjs/components/prism-sql"; +import "prismjs/components/prism-yaml"; +import "prismjs/components/prism-markdown"; +import "prismjs/components/prism-json"; +import "prismjs/components/prism-perl"; + +import "./_styles/prism-twilight.css"; +import "./_styles/prism.twilight.min.css"; + +async function getData(slug) { + const query = ` + *[_type == "blog" && slug.current == '${slug.replace(/'/g, "\\'")}'] { + "currentSlug": slug.current, + title, + content, + smallDescription, + titleImage, + _createdAt + }[0]`; + + const data = await client.fetch(query, {}, { cache: "no-store" }); + return data; +} + +export const dynamic = "force-static"; + +export async function generateMetadata(props) { + const params = await props.params; + const data = await getData(params.slug); + + if (!data) { + return { + title: "404 Not Found - MERNMail", + openGraph: { + title: "404 Not Found - MERNMail" + }, + twitter: { + title: "404 Not Found - MERNMail" + } + }; + } + + return { + title: `${data.title} - MERNMail`, + description: data.smallDescription, + openGraph: { + title: `${data.title} - MERNMail`, + description: data.smallDescription, + url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog/${data.currentSlug}`, + type: "website", + images: [ + { + url: data.titleImage + ? urlFor(data.titleImage).url() + : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog-missing.png`, + width: 2560, + height: 1440, + alt: `${data.title} - MERNMail` + } + ] + }, + twitter: { + card: "summary_large_image", + site: "@MERNMail", + title: `${data.title} - MERNMail`, + description: data.smallDescription, + images: [ + data.titleImage + ? urlFor(data.titleImage).url() + : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog-missing.png` + ], + creator: "@MERNMail" + } + }; +} + +const customPortableTextComponents = { + types: { + image: ({ value }) => { + return ( +
+ {value.alt + {value.caption && ( +

+ {value.caption} +

+ )} +
+ ); + }, + code: ({ value }) => { + const language = value.language || "none"; + const grammar = Prism.languages[language]; + + if (language != "none" && !grammar) { + console.error(`No grammar found for language: "${language}"`); + } + + return ( +
+
+            {value.code}
+          
+ {language == "none" ? "" : } +
+ ); + } + } +}; + +export default async function BlogSlugArticle(props) { + const params = await props.params; + const data = await getData(params.slug); + + if (!data) { + notFound(); + } + + const formattedDate = format(new Date(data._createdAt), "MMMM d, yyyy"); + + return ( + <> +
+
+ + + Back + + + + Subscribe to RSS + +
+
+
+

+ {data.title} +

+ {data.title} +

+ Published on: {formattedDate} +

+
+
+
+
+ +
+
+ + ); +} + +export async function generateStaticParams() { + const query = `*[_type == 'blog']{ + "slug": slug.current, + }`; + + const slugsRaw = await client.fetch(query); + + return slugsRaw; +} diff --git a/app/(root)/blog/page.jsx b/app/(root)/blog/page.jsx new file mode 100644 index 0000000..b19c7b7 --- /dev/null +++ b/app/(root)/blog/page.jsx @@ -0,0 +1,65 @@ +import { Rss } from "lucide-react"; +import Link from "next/link"; +import BlogCards from "@/components/BlogCards"; + +export const dynamic = "force-static"; + +export const metadata = { + title: "Blog - MERNMail", + description: + "Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.", + openGraph: { + title: "Blog - MERNMail", + description: + "Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.", + url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog`, + type: "website", + images: [ + { + url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`, + width: 2560, + height: 1440, + alt: "Blog - MERNMail" + } + ] + }, + twitter: { + card: "summary_large_image", + site: "@MERNMail", + title: "Blog - MERNMail", + description: + "Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.", + images: [ + `${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png` + ], + creator: "@MERNMail" + } +}; + +async function Blog() { + return ( +
+

+ MERNMail Blog +

+

+ Our blog has email-related tips and updates about MERNMail. + + + RSS feed + +

+ +
+ ); +} + +export default Blog; diff --git a/app/(root)/blog/page/[id]/page.jsx b/app/(root)/blog/page/[id]/page.jsx new file mode 100644 index 0000000..315db99 --- /dev/null +++ b/app/(root)/blog/page/[id]/page.jsx @@ -0,0 +1,74 @@ +import { Rss } from "lucide-react"; +import Link from "next/link"; +import BlogCards from "@/components/BlogCards"; + +export const dynamic = "force-static"; + +export async function generateMetadata({ params }) { + const obtainedParams = await params; + + return { + title: "Blog - MERNMail", + description: + "Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.", + openGraph: { + title: "Blog - MERNMail", + description: + "Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.", + url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog/page/${params.id}`, + type: "website", + images: [ + { + url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`, + width: 2560, + height: 1440, + alt: "Blog - MERNMail" + } + ] + }, + twitter: { + card: "summary_large_image", + site: "@MERNMail", + title: "Blog - MERNMail", + description: + "Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.", + images: [ + `${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png` + ], + creator: "@MERNMail" + } + }; +} + +async function Blog(props) { + const params = await props.params; + // Optionally, you can fetch some initial data here if needed. + let id = parseInt(params.id); + if (isNaN(id)) id = 1; + + return ( +
+

+ MERNMail Blog +

+

+ Our blog has email-related tips and updates about MERNMail. + + + RSS feed + +

+ +
+ ); +} + +export default Blog; diff --git a/app/api/revalidate/route.js b/app/api/revalidate/route.js new file mode 100644 index 0000000..c552be3 --- /dev/null +++ b/app/api/revalidate/route.js @@ -0,0 +1,80 @@ +/** + * This code is responsible for revalidating the cache when a post or author is updated. + * + * It is set up to receive a validated GROQ-powered Webhook from Sanity.io: + * https://www.sanity.io/docs/webhooks + * + * 1. Go to the API section of your Sanity project on sanity.io/manage or run `npx sanity hook create` + * 2. Click "Create webhook" + * 3. Set the URL to https://YOUR_NEXTJS_SITE_URL/api/revalidate + * 4. Dataset: Choose desired dataset or leave at default "all datasets" + * 5. Trigger on: "Create", "Update", and "Delete" + * 6. Filter: _type == "blog" + * 7. Projection: Leave empty + * 8. Status: Enable webhook + * 9. HTTP method: POST + * 10. HTTP Headers: Leave empty + * 11. API version: v2023-08-08 + * 12. Include drafts: No + * 13. Secret: Set to the same value as SANITY_REVALIDATE_SECRET (create a random secret if you haven't yet) + * 14. Save the cofiguration + */ + +import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook"; +import { client } from "@/sanity/lib/client"; +import { revalidatePath } from "next/cache"; +import { NextResponse } from "next/server"; + +const secret = `${process.env.SANITY_WEBHOOK_SECRET}`; + +export async function POST(req) { + const body = await req.json(); + const rawBody = JSON.stringify(body); + + if ( + !(await isValidSignature( + rawBody, + req.headers.get(SIGNATURE_HEADER_NAME) ?? "", + secret.trim() + )) + ) { + return NextResponse.json({ message: "Invalid signature" }, { status: 401 }); + } + + try { + if (body._type == "blog") { + if (body.slug.current) { + revalidatePath(`/blog/${body.slug.current}`); + revalidatePath("/sitemap.xml"); + revalidatePath("/rss.xml"); + } + revalidatePath("/blog"); + + // Change in /blog/page/[id] route and in BlogCards component too! + const cardsPerPage = 6; + + const totalPostsQuery = `count(*[_type == 'blog'])`; + const totalPosts = await client.fetch( + totalPostsQuery, + {}, + { cache: "no-store" } + ); + + const totalPages = Math.ceil(totalPosts / cardsPerPage); + for (let i = 1; i <= totalPages + 1; i++) { + revalidatePath(`/blog/page/${i.toString()}`); + } + + return NextResponse.json({ + message: `Revalidated "${body._type}" with slug "${body.slug.current}"` + }); + } + + return NextResponse.json({ message: "No managed type" }); + } catch (err) { + return NextResponse.json( + { message: "Error revalidating" }, + { status: 500 } + ); + } +} diff --git a/app/rss.xml/route.js b/app/rss.xml/route.js new file mode 100644 index 0000000..3f4da22 --- /dev/null +++ b/app/rss.xml/route.js @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import RSS from "rss"; +import { client, urlFor } from "@/sanity/lib/client"; +import { toHTML } from "@portabletext/to-html"; + +export const dynamic = "force-static"; + +export async function GET() { + const postsQuery = `*[_type == 'blog'] | order(_createdAt desc) { + title, + "slug": slug.current, + content, + titleImage, + _createdAt + }`; + + const posts = await client.fetch(postsQuery); + + const feed = new RSS({ + title: "MERNMail Blog", + description: "Explore the latest blog posts from MERNMail", + feed_url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/rss.xml`, + site_url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}`, + image_url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`, + language: "en-US", + pubDate: new Date().toUTCString() + }); + + posts.forEach((post) => { + feed.item({ + title: post.title, + description: toHTML(post.content), + url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog/${post.slug}`, + date: new Date(post._createdAt).toUTCString(), + enclosure: { + url: post.titleImage + ? urlFor(post.titleImage).url() + : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog-missing.png` + }, + author: "MERNMail" + }); + }); + + return new NextResponse(feed.xml({ indent: true }), { + headers: { + "Content-Type": "application/xml" + } + }); +} diff --git a/components/BlogCards.jsx b/components/BlogCards.jsx new file mode 100644 index 0000000..228ee3e --- /dev/null +++ b/components/BlogCards.jsx @@ -0,0 +1,151 @@ +import React from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { ChevronLeft, ChevronRight, ExternalLink } from "lucide-react"; +import { client, urlFor } from "@/sanity/lib/client"; +import { format } from "date-fns"; +import PropTypes from "prop-types"; + +const BlogCards = async (props) => { + "use server"; + + // Change in /blog/page/[id] route and in /api/revalidate route too! + const cardsPerPage = 6; + const currentPage = props.page; + + const query = `*[_type == 'blog'] | order(_createdAt desc) { + title, + smallDescription, + "currentSlug": slug.current, + titleImage, + _createdAt + }[${(currentPage - 1) * cardsPerPage}...${currentPage * cardsPerPage}]`; + + const posts = await client.fetch(query, {}, { cache: "no-store" }); + + const totalPostsQuery = `count(*[_type == 'blog'])`; + const totalPosts = await client.fetch( + totalPostsQuery, + {}, + { cache: "no-store" } + ); + + const totalPages = Math.ceil(totalPosts / cardsPerPage); + + let begPage = currentPage - 2; + let endPage = currentPage + 2; + if (endPage > totalPages) { + begPage -= endPage - totalPages; + endPage = totalPages; + } + if (begPage < 1) { + endPage += 1 - begPage; + begPage = 1; + } + + return ( + <> +
+ {posts.map((post, idx) => { + const formattedDate = format( + new Date(post._createdAt), + "MMMM d, yyyy" + ); + + const truncatedDescription = + post.smallDescription.length > 130 + ? post.smallDescription.substring(0, 130) + "..." + : post.smallDescription; + + return ( +
+
+ +
+ {post.title} +
+
+
+

+ {post.title} +

+
+ +
+
+

+ {truncatedDescription} +

+

+ Published on: {formattedDate} +

+
+ +
+
+ ); + })} +
+ { +
+ {totalPages > 1 && ( + + )} +
+ } + + ); +}; + +BlogCards.propTypes = { + page: PropTypes.number.isRequired +}; + +export default BlogCards; diff --git a/components/PrismLoader.jsx b/components/PrismLoader.jsx new file mode 100644 index 0000000..73382b6 --- /dev/null +++ b/components/PrismLoader.jsx @@ -0,0 +1,26 @@ +"use client"; +import { useEffect } from "react"; +import Prism from "prismjs"; +import "prismjs/themes/prism-okaidia.css"; +import "prismjs/components/prism-javascript"; +import "prismjs/components/prism-python"; +import "prismjs/components/prism-php"; +import "prismjs/components/prism-bash"; +import "prismjs/components/prism-sql"; +import "prismjs/components/prism-yaml"; +import "prismjs/components/prism-markdown"; +import "prismjs/components/prism-json"; +import "prismjs/components/prism-perl"; +import "prismjs/components/prism-markup"; +import "prismjs/components/prism-markup-templating"; +import "prismjs/components/prism-handlebars"; + +export default function PrismLoader() { + useEffect(() => { + if (Prism) { + Prism.highlightAll(); + } + }, []); + + return null; +} diff --git a/package-lock.json b/package-lock.json index 3d15abe..c39642d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,15 @@ "version": "0.1.0", "dependencies": { "@hcaptcha/react-hcaptcha": "^1.11.0", + "@portabletext/react": "^3.1.0", + "@portabletext/to-html": "^2.0.13", "@sanity/code-input": "^4.1.4", "@sanity/icons": "^3.4.0", "@sanity/image-url": "^1.1.0", "@sanity/vision": "^3.63.0", + "@sanity/webhook": "4.0.2-bc", "@tailwindcss/typography": "^0.5.15", + "date-fns": "^4.1.0", "globby": "^14.0.2", "lucide-react": "^0.454.0", "next": "^15.0.2", @@ -26,6 +30,7 @@ "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "rehype-prism": "^2.3.3", + "rss": "^1.2.2", "sanity": "^3.63.0", "styled-components": "^6.1.13", "validator": "^13.12.0" @@ -3930,6 +3935,18 @@ "react": "^17 || ^18 || >=19.0.0-rc" } }, + "node_modules/@portabletext/to-html": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@portabletext/to-html/-/to-html-2.0.13.tgz", + "integrity": "sha512-T3zL+2RcPCPGCp7rRrGrNJnGAqkdlpiOZnb/wh4tjDYJevteGY+5hmA0/5idLXzLiPv6vT8Gld852Sc0aFXwUA==", + "dependencies": { + "@portabletext/toolkit": "^2.0.15", + "@portabletext/types": "^2.0.13" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, "node_modules/@portabletext/toolkit": { "version": "2.0.16", "resolved": "https://registry.npmjs.org/@portabletext/toolkit/-/toolkit-2.0.16.tgz", @@ -4899,6 +4916,14 @@ "node": ">=6" } }, + "node_modules/@sanity/webhook": { + "version": "4.0.2-bc", + "resolved": "https://registry.npmjs.org/@sanity/webhook/-/webhook-4.0.2-bc.tgz", + "integrity": "sha512-I/Qq+ppPMkdZ2lQ3iHJ1HylBkEy+imn5qCOWEJefdVIyWdYPpNmTAH09exU6K6M1HRMM7Au4oOdijx3kruZEWA==", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@sentry-internal/browser-utils": { "version": "8.37.1", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.37.1.tgz", @@ -8096,18 +8121,12 @@ "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==" }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/date-now": { @@ -16563,6 +16582,34 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" }, + "node_modules/rss": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rss/-/rss-1.2.2.tgz", + "integrity": "sha512-xUhRTgslHeCBeHAqaWSbOYTydN2f0tAzNXvzh3stjz7QDhQMzdgHf3pfgNIngeytQflrFPfy6axHilTETr6gDg==", + "dependencies": { + "mime-types": "2.1.13", + "xml": "1.0.1" + } + }, + "node_modules/rss/node_modules/mime-db": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz", + "integrity": "sha512-5k547tI4Cy+Lddr/hdjNbBEWBwSl8EBc5aSdKvedav8DReADgWJzcYiktaRIw3GtGC1jjwldXtTzvqJZmtvC7w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rss/node_modules/mime-types": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz", + "integrity": "sha512-ryBDp1Z/6X90UvjUK3RksH0IBPM137T7cmg4OgD5wQBojlAiUwuok0QeELkim/72EtcYuNlmbkrcGuxj3Kl0YQ==", + "dependencies": { + "mime-db": "~1.25.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -16848,6 +16895,21 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, + "node_modules/sanity/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/sanity/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -19732,6 +19794,11 @@ "node": ">=8" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 55dfc74..1837eab 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,15 @@ }, "dependencies": { "@hcaptcha/react-hcaptcha": "^1.11.0", + "@portabletext/react": "^3.1.0", + "@portabletext/to-html": "^2.0.13", "@sanity/code-input": "^4.1.4", "@sanity/icons": "^3.4.0", "@sanity/image-url": "^1.1.0", "@sanity/vision": "^3.63.0", + "@sanity/webhook": "4.0.2-bc", "@tailwindcss/typography": "^0.5.15", + "date-fns": "^4.1.0", "globby": "^14.0.2", "lucide-react": "^0.454.0", "next": "^15.0.2", @@ -30,6 +34,7 @@ "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "rehype-prism": "^2.3.3", + "rss": "^1.2.2", "sanity": "^3.63.0", "styled-components": "^6.1.13", "validator": "^13.12.0" diff --git a/public/blog-missing.png b/public/blog-missing.png new file mode 100644 index 0000000..bf2e891 Binary files /dev/null and b/public/blog-missing.png differ diff --git a/sanity/env.js b/sanity/env.js index 3fc02d5..cc00360 100644 --- a/sanity/env.js +++ b/sanity/env.js @@ -3,3 +3,4 @@ export const apiVersion = export const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET; export const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID; +export const token = process.env.SANITY_AUTH_TOKEN; diff --git a/sanity/lib/client.js b/sanity/lib/client.js index 05f5aa0..2dca996 100644 --- a/sanity/lib/client.js +++ b/sanity/lib/client.js @@ -1,10 +1,18 @@ import { createClient } from "next-sanity"; +import imageUrlBuilder from "@sanity/image-url"; -import { apiVersion, dataset, projectId } from "../env"; +import { apiVersion, dataset, projectId, token } from "../env"; export const client = createClient({ projectId, dataset, apiVersion, - useCdn: true // Set to false if statically generating pages, using ISR or tag-based revalidation + token, + useCdn: false // Set to false if statically generating pages, using ISR or tag-based revalidation }); + +const builder = imageUrlBuilder(client); + +export function urlFor(source) { + return builder.image(source); +}