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

View 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>
)
}

View 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('&', '&amp;')
: 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
View 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">
Whats 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>
)
}