diff --git a/src/components/BlogAuthorFilter.tsx b/src/components/BlogAuthorFilter.tsx
new file mode 100644
index 000000000..8a8da1e41
--- /dev/null
+++ b/src/components/BlogAuthorFilter.tsx
@@ -0,0 +1,133 @@
+import { Check, ChevronsUpDown } from 'lucide-react'
+import { twMerge } from 'tailwind-merge'
+import {
+ Dropdown,
+ DropdownContent,
+ DropdownItem,
+ DropdownSeparator,
+ DropdownTrigger,
+} from '~/components/Dropdown'
+import { findMaintainerByAuthorName } from '~/utils/authors'
+import authorFallbackAvatar from '~/images/author-fallback.svg'
+
+function getAuthorAvatar(name: string): string {
+ return findMaintainerByAuthorName(name)?.avatar ?? authorFallbackAvatar
+}
+
+type BlogAuthorFilterProps = {
+ authors: string[]
+ selected: string | undefined
+ onSelect: (author: string | undefined) => void
+ className?: string
+}
+
+export function BlogAuthorFilter({
+ authors,
+ selected,
+ onSelect,
+ className,
+}: BlogAuthorFilterProps) {
+ // Ignore URL values that don't correspond to a real author in the list.
+ const activeAuthor =
+ selected && authors.includes(selected) ? selected : undefined
+
+ return (
+
+
+
+
+
+
+ onSelect(undefined)}
+ className={twMerge(
+ 'pr-8 relative',
+ !activeAuthor
+ ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-200 font-medium'
+ : 'font-normal',
+ )}
+ >
+ All authors
+ {!activeAuthor ? (
+
+ ) : null}
+
+ {authors.length > 0 ? : null}
+ {authors.map((name) => {
+ const isSelected = activeAuthor === name
+ return (
+ onSelect(name)}
+ className={twMerge(
+ 'pr-8 pl-2 relative',
+ isSelected
+ ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-200 font-medium'
+ : 'font-normal',
+ )}
+ >
+
+ {name}
+ {isSelected ? (
+
+ ) : null}
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/components/BlogCard.tsx b/src/components/BlogCard.tsx
new file mode 100644
index 000000000..43a3dd91f
--- /dev/null
+++ b/src/components/BlogCard.tsx
@@ -0,0 +1,86 @@
+import { Link } from '@tanstack/react-router'
+import { Card } from '~/components/Card'
+import {
+ formatAuthors,
+ formatPublishedDate,
+ getBlogLibraries,
+} from '~/utils/blog'
+import { getNetlifyImageUrl } from '~/utils/netlifyImage'
+
+export type BlogCardPost = {
+ slug: string
+ title: string
+ published: string
+ excerpt: string
+ headerImage: string | undefined
+ authors: string[]
+ library: string | undefined
+}
+
+type BlogCardProps = {
+ post: BlogCardPost
+ showLibraryBadges?: boolean
+}
+
+export function BlogCard({ post, showLibraryBadges = true }: BlogCardProps) {
+ const { slug, title, published, excerpt, headerImage, authors, library } =
+ post
+ const blogLibraries = showLibraryBadges ? getBlogLibraries(library) : []
+
+ return (
+
+ {blogLibraries.length ? (
+
+ {blogLibraries.map((blogLibrary) => (
+
+ {blogLibrary.name.replace('TanStack ', '')}
+
+ ))}
+
+ ) : null}
+ {headerImage ? (
+
+
})
+
+ ) : null}
+
+
+
{title}
+
+ by {formatAuthors(authors)}
+ {published ? (
+
+ ) : null}
+
+ {excerpt ? (
+
+ {excerpt}
+
+ ) : null}
+
+
+
+
+ )
+}
diff --git a/src/components/DocsLayout.tsx b/src/components/DocsLayout.tsx
index 9335ddc29..84ef7a89c 100644
--- a/src/components/DocsLayout.tsx
+++ b/src/components/DocsLayout.tsx
@@ -438,6 +438,10 @@ const useMenuConfig = ({
label: 'Home',
to: '..',
},
+ {
+ label: 'Blog',
+ to: '/$libraryId/$version/docs/blog',
+ },
...(frameworks.length > 1
? [
{
diff --git a/src/components/shop/ui/Select.tsx b/src/components/shop/ui/Select.tsx
index 50b63d00b..dc98ffcb1 100644
--- a/src/components/shop/ui/Select.tsx
+++ b/src/components/shop/ui/Select.tsx
@@ -94,12 +94,13 @@ export function ShopSelect({
}
return (
-
+