init
This commit is contained in:
200
src/app/blog/[slug]/page.tsx
Normal file
200
src/app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Button } from '@/components/button'
|
||||
import { Container } from '@/components/container'
|
||||
import { Footer } from '@/components/footer'
|
||||
import { GradientBackground } from '@/components/gradient'
|
||||
import { Link } from '@/components/link'
|
||||
import { Navbar } from '@/components/navbar'
|
||||
import { Heading, Subheading } from '@/components/text'
|
||||
import { image } from '@/sanity/image'
|
||||
import { getPost } from '@/sanity/queries'
|
||||
import { ChevronLeftIcon } from '@heroicons/react/16/solid'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Metadata } from 'next'
|
||||
import { PortableText } from 'next-sanity'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string }
|
||||
}): Promise<Metadata> {
|
||||
let post = await getPost(params.slug)
|
||||
|
||||
return post ? { title: post.title, description: post.excerpt } : {}
|
||||
}
|
||||
|
||||
export default async function BlogPost({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string }
|
||||
}) {
|
||||
let post = (await getPost(params.slug)) || notFound()
|
||||
|
||||
return (
|
||||
<main className="overflow-hidden">
|
||||
<GradientBackground />
|
||||
<Container>
|
||||
<Navbar />
|
||||
<Subheading className="mt-16">
|
||||
{dayjs(post.publishedAt).format('dddd, MMMM D, YYYY')}
|
||||
</Subheading>
|
||||
<Heading as="h1" className="mt-2">
|
||||
{post.title}
|
||||
</Heading>
|
||||
<div className="mt-16 grid grid-cols-1 gap-8 pb-24 lg:grid-cols-[15rem_1fr] xl:grid-cols-[15rem_1fr_15rem]">
|
||||
<div className="flex flex-wrap items-center gap-8 max-lg:justify-between lg:flex-col lg:items-start">
|
||||
{post.author && (
|
||||
<div className="flex items-center gap-3">
|
||||
{post.author.image && (
|
||||
<img
|
||||
alt=""
|
||||
src={image(post.author.image).size(64, 64).url()}
|
||||
className="aspect-square size-6 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="text-sm/5 text-gray-700">
|
||||
{post.author.name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(post.categories) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.categories.map((category) => (
|
||||
<Link
|
||||
key={category.slug}
|
||||
href={`/blog?category=${category.slug}`}
|
||||
className="rounded-full border border-dotted border-gray-300 bg-gray-50 px-2 text-sm/6 font-medium text-gray-500"
|
||||
>
|
||||
{category.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
<div className="max-w-2xl xl:mx-auto">
|
||||
{post.mainImage && (
|
||||
<img
|
||||
alt={post.mainImage.alt || ''}
|
||||
src={image(post.mainImage).size(2016, 1344).url()}
|
||||
className="mb-10 aspect-3/2 w-full rounded-2xl object-cover shadow-xl"
|
||||
/>
|
||||
)}
|
||||
{post.body && (
|
||||
<PortableText
|
||||
value={post.body}
|
||||
components={{
|
||||
block: {
|
||||
normal: ({ children }) => (
|
||||
<p className="my-10 text-base/8 first:mt-0 last:mb-0">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mt-12 mb-10 text-2xl/8 font-medium tracking-tight text-gray-950 first:mt-0 last:mb-0">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mt-12 mb-10 text-xl/8 font-medium tracking-tight text-gray-950 first:mt-0 last:mb-0">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-10 border-l-2 border-l-gray-300 pl-6 text-base/8 text-gray-950 first:mt-0 last:mb-0">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
},
|
||||
types: {
|
||||
image: ({ value }) => (
|
||||
<img
|
||||
alt={value.alt || ''}
|
||||
src={image(value).width(2000).url()}
|
||||
className="w-full rounded-2xl"
|
||||
/>
|
||||
),
|
||||
separator: ({ value }) => {
|
||||
switch (value.style) {
|
||||
case 'line':
|
||||
return (
|
||||
<hr className="my-8 border-t border-gray-200" />
|
||||
)
|
||||
case 'space':
|
||||
return <div className="my-8" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
},
|
||||
list: {
|
||||
bullet: ({ children }) => (
|
||||
<ul className="list-disc pl-4 text-base/8 marker:text-gray-400">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
number: ({ children }) => (
|
||||
<ol className="list-decimal pl-4 text-base/8 marker:text-gray-400">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
},
|
||||
listItem: {
|
||||
bullet: ({ children }) => {
|
||||
return (
|
||||
<li className="my-2 pl-2 has-[br]:mb-8">
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
number: ({ children }) => {
|
||||
return (
|
||||
<li className="my-2 pl-2 has-[br]:mb-8">
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
},
|
||||
marks: {
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-gray-950">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<>
|
||||
<span aria-hidden>`</span>
|
||||
<code className="text-[15px]/8 font-semibold text-gray-950">
|
||||
{children}
|
||||
</code>
|
||||
<span aria-hidden>`</span>
|
||||
</>
|
||||
),
|
||||
link: ({ value, children }) => {
|
||||
return (
|
||||
<Link
|
||||
href={value.href}
|
||||
className="font-medium text-gray-950 underline decoration-gray-400 underline-offset-4 data-hover:decoration-gray-600"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-10">
|
||||
<Button variant="outline" href="/blog">
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
Back to blog
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
<Footer />
|
||||
</main>
|
||||
)
|
||||
}
|
65
src/app/blog/feed.xml/route.ts
Normal file
65
src/app/blog/feed.xml/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { image } from '@/sanity/image'
|
||||
import { getPostsForFeed } from '@/sanity/queries'
|
||||
import { Feed } from 'feed'
|
||||
import assert from 'node:assert'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
let siteUrl = new URL(req.url).origin
|
||||
|
||||
let feed = new Feed({
|
||||
title: 'The Radiant Blog',
|
||||
description:
|
||||
'Stay informed with product updates, company news, and insights on how to sell smarter at your company.',
|
||||
author: {
|
||||
name: 'Michael Foster',
|
||||
email: 'michael.foster@example.com',
|
||||
},
|
||||
id: siteUrl,
|
||||
link: siteUrl,
|
||||
image: `${siteUrl}/favicon.ico`,
|
||||
favicon: `${siteUrl}/favicon.ico`,
|
||||
copyright: `All rights reserved ${new Date().getFullYear()}`,
|
||||
feedLinks: {
|
||||
rss2: `${siteUrl}/feed.xml`,
|
||||
},
|
||||
})
|
||||
|
||||
let posts = await getPostsForFeed()
|
||||
|
||||
posts.forEach((post) => {
|
||||
try {
|
||||
assert(typeof post.title === 'string')
|
||||
assert(typeof post.slug === 'string')
|
||||
assert(typeof post.excerpt === 'string')
|
||||
assert(typeof post.publishedAt === 'string')
|
||||
} catch (error) {
|
||||
console.log('Post is missing required fields for RSS feed:', post)
|
||||
return
|
||||
}
|
||||
|
||||
feed.addItem({
|
||||
title: post.title,
|
||||
id: post.slug,
|
||||
link: `${siteUrl}/blog/${post.slug}`,
|
||||
content: post.excerpt,
|
||||
image: post.mainImage
|
||||
? image(post.mainImage)
|
||||
.size(1200, 800)
|
||||
.format('jpg')
|
||||
.url()
|
||||
.replaceAll('&', '&')
|
||||
: undefined,
|
||||
author: post.author?.name ? [{ name: post.author.name }] : [],
|
||||
contributor: post.author?.name ? [{ name: post.author.name }] : [],
|
||||
date: new Date(post.publishedAt),
|
||||
})
|
||||
})
|
||||
|
||||
return new Response(feed.rss2(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/xml',
|
||||
'cache-control': 's-maxage=31556952',
|
||||
},
|
||||
})
|
||||
}
|
310
src/app/blog/page.tsx
Normal file
310
src/app/blog/page.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { Button } from '@/components/button'
|
||||
import { Container } from '@/components/container'
|
||||
import { Footer } from '@/components/footer'
|
||||
import { GradientBackground } from '@/components/gradient'
|
||||
import { Link } from '@/components/link'
|
||||
import { Navbar } from '@/components/navbar'
|
||||
import { Heading, Lead, Subheading } from '@/components/text'
|
||||
import { image } from '@/sanity/image'
|
||||
import {
|
||||
getCategories,
|
||||
getFeaturedPosts,
|
||||
getPosts,
|
||||
getPostsCount,
|
||||
} from '@/sanity/queries'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpDownIcon,
|
||||
RssIcon,
|
||||
} from '@heroicons/react/16/solid'
|
||||
import { clsx } from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog',
|
||||
description:
|
||||
'Stay informed with product updates, company news, and insights on how to sell smarter at your company.',
|
||||
}
|
||||
|
||||
const postsPerPage = 5
|
||||
|
||||
async function FeaturedPosts() {
|
||||
let featuredPosts = await getFeaturedPosts(3)
|
||||
|
||||
if (featuredPosts.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-16 bg-linear-to-t from-gray-100 pb-14">
|
||||
<Container>
|
||||
<h2 className="text-2xl font-medium tracking-tight">Featured</h2>
|
||||
<div className="mt-6 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{featuredPosts.map((post) => (
|
||||
<div
|
||||
key={post.slug}
|
||||
className="relative flex flex-col rounded-3xl bg-white p-2 shadow-md ring-1 shadow-black/5 ring-black/5"
|
||||
>
|
||||
{post.mainImage && (
|
||||
<img
|
||||
alt={post.mainImage.alt || ''}
|
||||
src={image(post.mainImage).size(1170, 780).url()}
|
||||
className="aspect-3/2 w-full rounded-2xl object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<div className="text-sm/5 text-gray-700">
|
||||
{dayjs(post.publishedAt).format('dddd, MMMM D, YYYY')}
|
||||
</div>
|
||||
<div className="mt-2 text-base/7 font-medium">
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
<span className="absolute inset-0" />
|
||||
{post.title}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex-1 text-sm/6 text-gray-500">
|
||||
{post.excerpt}
|
||||
</div>
|
||||
{post.author && (
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
{post.author.image && (
|
||||
<img
|
||||
alt=""
|
||||
src={image(post.author.image).size(64, 64).url()}
|
||||
className="aspect-square size-6 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="text-sm/5 text-gray-700">
|
||||
{post.author.name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function Categories({ selected }: { selected?: string }) {
|
||||
let categories = await getCategories()
|
||||
|
||||
if (categories.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Menu>
|
||||
<MenuButton className="flex items-center justify-between gap-2 font-medium">
|
||||
{categories.find(({ slug }) => slug === selected)?.title ||
|
||||
'All categories'}
|
||||
<ChevronUpDownIcon className="size-4 fill-gray-900" />
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
anchor="bottom start"
|
||||
className="min-w-40 rounded-lg bg-white p-1 shadow-lg ring-1 ring-gray-200 [--anchor-gap:6px] [--anchor-offset:-4px] [--anchor-padding:10px]"
|
||||
>
|
||||
<MenuItem>
|
||||
<Link
|
||||
href="/blog"
|
||||
data-selected={selected === undefined ? true : undefined}
|
||||
className="group grid grid-cols-[1rem_1fr] items-center gap-2 rounded-md px-2 py-1 data-focus:bg-gray-950/5"
|
||||
>
|
||||
<CheckIcon className="hidden size-4 group-data-selected:block" />
|
||||
<p className="col-start-2 text-sm/6">All categories</p>
|
||||
</Link>
|
||||
</MenuItem>
|
||||
{categories.map((category) => (
|
||||
<MenuItem key={category.slug}>
|
||||
<Link
|
||||
href={`/blog?category=${category.slug}`}
|
||||
data-selected={category.slug === selected ? true : undefined}
|
||||
className="group grid grid-cols-[16px_1fr] items-center gap-2 rounded-md px-2 py-1 data-focus:bg-gray-950/5"
|
||||
>
|
||||
<CheckIcon className="hidden size-4 group-data-selected:block" />
|
||||
<p className="col-start-2 text-sm/6">{category.title}</p>
|
||||
</Link>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
<Button variant="outline" href="/blog/feed.xml" className="gap-1">
|
||||
<RssIcon className="size-4" />
|
||||
RSS Feed
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function Posts({ page, category }: { page: number; category?: string }) {
|
||||
let posts = await getPosts(
|
||||
(page - 1) * postsPerPage,
|
||||
page * postsPerPage,
|
||||
category,
|
||||
)
|
||||
|
||||
if (posts.length === 0 && (page > 1 || category)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
return <p className="mt-6 text-gray-500">No posts found.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{posts.map((post) => (
|
||||
<div
|
||||
key={post.slug}
|
||||
className="relative grid grid-cols-1 border-b border-b-gray-100 py-10 first:border-t first:border-t-gray-200 max-sm:gap-3 sm:grid-cols-3"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm/5 max-sm:text-gray-700 sm:font-medium">
|
||||
{dayjs(post.publishedAt).format('dddd, MMMM D, YYYY')}
|
||||
</div>
|
||||
{post.author && (
|
||||
<div className="mt-2.5 flex items-center gap-3">
|
||||
{post.author.image && (
|
||||
<img
|
||||
alt=""
|
||||
src={image(post.author.image).width(64).height(64).url()}
|
||||
className="aspect-square size-6 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="text-sm/5 text-gray-700">
|
||||
{post.author.name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:col-span-2 sm:max-w-2xl">
|
||||
<h2 className="text-sm/5 font-medium">{post.title}</h2>
|
||||
<p className="mt-3 text-sm/6 text-gray-500">{post.excerpt}</p>
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="flex items-center gap-1 text-sm/5 font-medium"
|
||||
>
|
||||
<span className="absolute inset-0" />
|
||||
Read more
|
||||
<ChevronRightIcon className="size-4 fill-gray-400" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function Pagination({
|
||||
page,
|
||||
category,
|
||||
}: {
|
||||
page: number
|
||||
category?: string
|
||||
}) {
|
||||
function url(page: number) {
|
||||
let params = new URLSearchParams()
|
||||
|
||||
if (category) params.set('category', category)
|
||||
if (page > 1) params.set('page', page.toString())
|
||||
|
||||
return params.size !== 0 ? `/blog?${params.toString()}` : '/blog'
|
||||
}
|
||||
|
||||
let totalPosts = await getPostsCount(category)
|
||||
let hasPreviousPage = page - 1
|
||||
let previousPageUrl = hasPreviousPage ? url(page - 1) : undefined
|
||||
let hasNextPage = page * postsPerPage < totalPosts
|
||||
let nextPageUrl = hasNextPage ? url(page + 1) : undefined
|
||||
let pageCount = Math.ceil(totalPosts / postsPerPage)
|
||||
|
||||
if (pageCount < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
href={previousPageUrl}
|
||||
disabled={!previousPageUrl}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<div className="flex gap-2 max-sm:hidden">
|
||||
{Array.from({ length: pageCount }, (_, i) => (
|
||||
<Link
|
||||
key={i + 1}
|
||||
href={url(i + 1)}
|
||||
data-active={i + 1 === page ? true : undefined}
|
||||
className={clsx(
|
||||
'size-7 rounded-lg text-center text-sm/7 font-medium',
|
||||
'data-hover:bg-gray-100',
|
||||
'data-active:shadow-sm data-active:ring-1 data-active:ring-black/10',
|
||||
'data-active:data-hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" href={nextPageUrl} disabled={!nextPageUrl}>
|
||||
Next
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function Blog({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | string[] | undefined }
|
||||
}) {
|
||||
let page =
|
||||
'page' in searchParams
|
||||
? typeof searchParams.page === 'string' && parseInt(searchParams.page) > 1
|
||||
? parseInt(searchParams.page)
|
||||
: notFound()
|
||||
: 1
|
||||
|
||||
let category =
|
||||
typeof searchParams.category === 'string'
|
||||
? searchParams.category
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<main className="overflow-hidden">
|
||||
<GradientBackground />
|
||||
<Container>
|
||||
<Navbar />
|
||||
<Subheading className="mt-16">Blog</Subheading>
|
||||
<Heading as="h1" className="mt-2">
|
||||
What’s happening at Radiant.
|
||||
</Heading>
|
||||
<Lead className="mt-6 max-w-3xl">
|
||||
Stay informed with product updates, company news, and insights on how
|
||||
to sell smarter at your company.
|
||||
</Lead>
|
||||
</Container>
|
||||
{page === 1 && !category && <FeaturedPosts />}
|
||||
<Container className="mt-16 pb-24">
|
||||
<Categories selected={category} />
|
||||
<Posts page={page} category={category} />
|
||||
<Pagination page={page} category={category} />
|
||||
</Container>
|
||||
<Footer />
|
||||
</main>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user