-
-
Notifications
You must be signed in to change notification settings - Fork 344
feat: blog post filters by author and new blog post entry page per li… #911
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className={twMerge('w-full', className)}> | ||
| <Dropdown> | ||
| <DropdownTrigger> | ||
| <button | ||
| type="button" | ||
| className={twMerge( | ||
| 'relative w-full flex items-center gap-2 rounded-md py-1.5 px-2 text-left text-sm cursor-pointer transition-colors', | ||
| 'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500', | ||
| activeAuthor | ||
| ? 'bg-blue-50 dark:bg-blue-950/40 ring-1 ring-blue-500/50 text-blue-700 dark:text-blue-200' | ||
| : 'hover:bg-gray-500/10', | ||
| )} | ||
| > | ||
| <span | ||
| className={twMerge( | ||
| 'flex items-center justify-center w-6 h-6 rounded border overflow-hidden flex-shrink-0', | ||
| activeAuthor ? 'border-blue-500/30' : 'border-gray-500/20', | ||
| )} | ||
| > | ||
| <img | ||
| height={20} | ||
| width={20} | ||
| src={ | ||
| activeAuthor | ||
| ? getAuthorAvatar(activeAuthor) | ||
| : authorFallbackAvatar | ||
| } | ||
| alt="" | ||
| className="w-full h-full object-cover" | ||
| /> | ||
| </span> | ||
| <span className="truncate font-medium flex-1"> | ||
| {activeAuthor ?? 'All authors'} | ||
| </span> | ||
| <span className="flex items-center pr-1"> | ||
| <ChevronsUpDown | ||
| className="h-4 w-4 opacity-40" | ||
| aria-hidden="true" | ||
| /> | ||
| </span> | ||
| </button> | ||
| </DropdownTrigger> | ||
| <DropdownContent | ||
| align="start" | ||
| className="max-h-80 overflow-auto min-w-[16rem]" | ||
| > | ||
| <DropdownItem | ||
| onSelect={() => 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', | ||
| )} | ||
| > | ||
| <span className="truncate">All authors</span> | ||
| {!activeAuthor ? ( | ||
| <Check | ||
| className="h-4 w-4 absolute right-2 text-blue-600 dark:text-blue-300" | ||
| aria-hidden="true" | ||
| /> | ||
| ) : null} | ||
| </DropdownItem> | ||
| {authors.length > 0 ? <DropdownSeparator /> : null} | ||
| {authors.map((name) => { | ||
| const isSelected = activeAuthor === name | ||
| return ( | ||
| <DropdownItem | ||
| key={name} | ||
| onSelect={() => 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', | ||
| )} | ||
| > | ||
| <img | ||
| height={20} | ||
| width={20} | ||
| src={getAuthorAvatar(name)} | ||
| alt="" | ||
| className="w-5 h-5 rounded object-cover flex-shrink-0" | ||
| /> | ||
| <span className="truncate">{name}</span> | ||
| {isSelected ? ( | ||
| <Check | ||
| className="h-4 w-4 absolute right-2 text-blue-600 dark:text-blue-300" | ||
| aria-hidden="true" | ||
| /> | ||
| ) : null} | ||
| </DropdownItem> | ||
| ) | ||
| })} | ||
| </DropdownContent> | ||
| </Dropdown> | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <Card | ||
| as={Link} | ||
| to="/blog/$" | ||
| params={{ _splat: slug } as never} | ||
| className="relative flex flex-col justify-between overflow-hidden transition-all hover:shadow-sm hover:border-blue-500" | ||
| > | ||
| {blogLibraries.length ? ( | ||
| <div className="absolute right-3 top-3 z-10 flex flex-wrap justify-end gap-1"> | ||
| {blogLibraries.map((blogLibrary) => ( | ||
| <div | ||
| key={blogLibrary.id} | ||
| className={`rounded-md px-2 py-1 text-xs font-black uppercase shadow-sm ${blogLibrary.bgStyle} ${blogLibrary.badgeTextStyle ?? 'text-white'}`} | ||
| > | ||
| {blogLibrary.name.replace('TanStack ', '')} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ) : null} | ||
| {headerImage ? ( | ||
| <div className="aspect-video overflow-hidden bg-gray-100 dark:bg-gray-800"> | ||
| <img | ||
| src={getNetlifyImageUrl(headerImage)} | ||
| alt="" | ||
| loading="lazy" | ||
| decoding="async" | ||
| className="w-full h-full object-cover" | ||
| /> | ||
| </div> | ||
| ) : null} | ||
| <div className="p-4 md:p-8 flex flex-col gap-4 flex-1 justify-between"> | ||
| <div> | ||
| <div className="text-lg font-extrabold">{title}</div> | ||
| <div className="text-xs italic font-light mt-1"> | ||
| by {formatAuthors(authors)} | ||
| {published ? ( | ||
| <time dateTime={published} title={formatPublishedDate(published)}> | ||
| {' '} | ||
| on {formatPublishedDate(published)} | ||
| </time> | ||
| ) : null} | ||
| </div> | ||
| {excerpt ? ( | ||
| <p className="text-sm mt-4 text-gray-600 dark:text-gray-400 leading-7 line-clamp-4"> | ||
| {excerpt} | ||
| </p> | ||
| ) : null} | ||
| </div> | ||
| <div> | ||
| <div className="text-blue-500 uppercase font-black text-sm"> | ||
| Read More | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </Card> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,9 +38,11 @@ export const allMaintainers: Maintainer[] = [ | |
| 'ranger', | ||
| 'store', | ||
| 'pacer', | ||
| 'cli', | ||
| 'mcp', | ||
| 'react-charts', | ||
| ], | ||
| maintainerOf: ['intent'], | ||
| frameworkExpertise: ['react', 'solid'], | ||
| specialties: ['Architecture', 'Core API', 'Documentation'], | ||
| social: { | ||
|
|
@@ -71,7 +73,7 @@ export const allMaintainers: Maintainer[] = [ | |
| github: 'crutchcorn', | ||
| creatorOf: ['form'], | ||
| maintainerOf: ['store', 'config'], | ||
| frameworkExpertise: ['react', 'solid', 'vue', 'angular'], | ||
| frameworkExpertise: ['react', 'solid', 'vue', 'angular', 'lit'], | ||
| specialties: ['Forms', 'Validation', 'State Management'], | ||
| social: { | ||
| twitter: 'https://x.com/crutchcorn', | ||
|
|
@@ -98,14 +100,15 @@ export const allMaintainers: Maintainer[] = [ | |
| avatar: 'https://github.com/kevinvandy.png', | ||
| github: 'kevinvandy', | ||
| creatorOf: ['pacer', 'hotkeys'], | ||
| maintainerOf: ['table'], | ||
| maintainerOf: ['table', 'store'], | ||
| contributorOf: ['virtual'], | ||
| consultantOf: ['query'], | ||
| frameworkExpertise: ['react', 'preact', 'solid'], | ||
| specialties: ['Tables', 'Data Grids', 'Dashboards'], | ||
| specialties: ['Data Grids', 'Performance', 'Dashboards'], | ||
| social: { | ||
| twitter: 'https://x.com/kevinvancott', | ||
| bluesky: 'https://bsky.app/profile/kevinvancott.dev', | ||
| linkedIn: 'https://www.linkedin.com/in/kevinthomasvancott', | ||
| website: 'https://kevinvancott.dev', | ||
| }, | ||
| workshopsAvailable: true, | ||
|
|
@@ -135,7 +138,7 @@ export const allMaintainers: Maintainer[] = [ | |
| }, | ||
| }, | ||
| { | ||
| name: 'Chris Horobin', | ||
| name: 'Christopher Horobin', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep backward compatibility for renamed author display names. Renaming 🤖 Prompt for AI Agents |
||
| isCoreMaintainer: true, | ||
| avatar: 'https://github.com/chorobin.png', | ||
| github: 'chorobin', | ||
|
|
@@ -161,12 +164,13 @@ export const allMaintainers: Maintainer[] = [ | |
| isCoreMaintainer: true, | ||
| avatar: 'https://github.com/KyleAMathews.png', | ||
| github: 'KyleAMathews', | ||
| creatorOf: ['db'], | ||
| creatorOf: ['db', 'intent'], | ||
| frameworkExpertise: ['react'], | ||
| specialties: ['Sync Engines'], | ||
| }, | ||
| { | ||
| name: 'Alem Tuzlak', | ||
| isCoreMaintainer: true, | ||
| avatar: 'https://github.com/AlemTuzlak.png', | ||
| github: 'AlemTuzlak', | ||
| creatorOf: ['ai', 'devtools'], | ||
|
|
@@ -304,6 +308,7 @@ export const allMaintainers: Maintainer[] = [ | |
| name: 'Sarah Gerrard', | ||
| avatar: 'https://github.com/ladybluenotes.png', | ||
| github: 'ladybluenotes', | ||
| creatorOf: ['intent'], | ||
| contributorOf: [ | ||
| 'ai', | ||
| 'config', | ||
|
|
@@ -334,6 +339,7 @@ export const allMaintainers: Maintainer[] = [ | |
| 'https://cdn.bsky.app/img/avatar/plain/did:plc:gtnigsmgu7jyrc4tnkvn62qw/bafkreiceysbj4o6jrbbniudtwj3tcsns6rvwcxyjsqiaumeojurwbkki5a@jpeg', | ||
| github: 'riccardoperra', | ||
| maintainerOf: ['table'], | ||
| contributorOf: ['devtools', 'hotkeys', 'pacer'], | ||
| frameworkExpertise: ['angular', 'solid'], | ||
| specialties: [], | ||
| social: { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a relative path for the Blog menu item to keep nav indexing correct.
With
to: '/$libraryId/$version/docs/blog', the item won’t match the relative path comparison used for prev/next derivation (Line 592), so navigation order can break on the blog docs page.Proposed diff
{ label: 'Blog', - to: '/$libraryId/$version/docs/blog', + to: './blog', },🤖 Prompt for AI Agents