diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index de949747..eb73a9cb 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -33,6 +33,12 @@ import { SearchButton } from './SearchButton' import { libraries, SIDEBAR_LIBRARY_IDS, type LibrarySlim } from '~/libraries' import { useClickOutside } from '~/hooks/useClickOutside' import { GithubIcon } from '~/components/icons/GithubIcon' +import { + Dropdown, + DropdownContent, + DropdownItem, + DropdownTrigger, +} from '~/components/Dropdown' import { DiscordIcon } from '~/components/icons/DiscordIcon' import { InstagramIcon } from '~/components/icons/InstagramIcon' import { BSkyIcon } from '~/components/icons/BSkyIcon' @@ -216,40 +222,7 @@ export function Navbar({ children }: { children: React.ReactNode }) { ) - const socialLinks = ( -
- - - - - - - - - - - - - - - - - - -
- ) + const socialLinks = const navbar = (
) } + +const SOCIAL_LINKS = [ + { + label: 'GitHub', + href: 'https://github.com/TanStack', + Icon: GithubIcon, + }, + { + label: 'Discord', + href: 'https://tlinz.com/discord', + Icon: DiscordIcon, + }, + { + label: 'YouTube', + href: 'https://youtube.com/@tan_stack', + Icon: YouTubeIcon, + }, + { + label: 'X (Twitter)', + href: 'https://x.com/tan_stack', + Icon: BrandXIcon, + }, + { + label: 'Bluesky', + href: 'https://bsky.app/profile/tanstack.com', + Icon: BSkyIcon, + }, + { + label: 'Instagram', + href: 'https://instagram.com/tan_stack', + Icon: InstagramIcon, + }, +] as const + +function SocialStack() { + const stackTop = SOCIAL_LINKS.slice(0, 3) + + return ( + + + + + + {SOCIAL_LINKS.map(({ label, href, Icon }) => ( + + + + {label} + + + ))} + + + ) +} diff --git a/src/components/stack/CategoryArticle.tsx b/src/components/stack/CategoryArticle.tsx new file mode 100644 index 00000000..a1b97838 --- /dev/null +++ b/src/components/stack/CategoryArticle.tsx @@ -0,0 +1,320 @@ +import * as React from 'react' +import { Link } from '@tanstack/react-router' +import { twMerge } from 'tailwind-merge' +import { + ArrowRight, + Award, + ChevronRight, + Layers, + Newspaper, + Sparkles, +} from 'lucide-react' +import { GitHub } from '~/ui' + +import type { LibrarySlim } from '~/libraries' +import { formatPublishedDate, getPostsForLibrary } from '~/utils/blog' +import { + categoryMeta, + getCategoryLibraries, + type CategorySlug, +} from './stack-categories' + +export function CategoryArticle({ slug }: { slug: CategorySlug }) { + const meta = categoryMeta[slug] + const libraries = getCategoryLibraries(slug) + const topPick = + libraries.find((lib) => lib.id === meta.topPickId) ?? libraries[0] + const relatedPosts = libraries + .flatMap((lib) => getPostsForLibrary(lib.id).map((p) => ({ post: p, lib }))) + .slice(0, 4) + + return ( +
+ {/* Breadcrumb */} +
+
+ + Home + + + + Libraries + + + + {meta.name} + +
+
+ + {/* Hero */} +
+
+

+ {meta.shortName} +

+

+ {meta.headline} +

+

+ {meta.intro} +

+
+
+ + {/* Body */} +
+
+ + + {relatedPosts.length > 0 && ( + + )} +
+
+
+ ) +} + +function TopPickBlock({ library }: { library: LibrarySlim }) { + return ( +
+ + Where to start + +

+ Start with {library.name} +

+ +
+
+

+ TanStack +

+

+ {library.name.replace('TanStack ', '')} +

+

+ {library.tagline} +

+
+ + Open the library + +
+
+
+ {library.description} +
+ + +
+ {library.frameworks.slice(0, 6).map((fw) => ( + + ))} + {library.frameworks.length > 6 && ( + + + {library.frameworks.length - 6} more frameworks + + )} + + tanstack/ + {library.repo?.split('/').pop()} + +
+
+ ) +} + +function FullListBlock({ + libraries, + topPickId, +}: { + libraries: LibrarySlim[] + topPickId: string +}) { + return ( +
+ + The full list + +

+ Every library in this category +

+
    + {libraries.map((lib, i) => ( + + ))} +
+
+ ) +} + +function LibraryEntry({ + library, + rank, + isTopPick, +}: { + library: LibrarySlim + rank: number + isTopPick: boolean +}) { + return ( +
  • +
    +
    + {rank} +
    +
    +
    +

    + TanStack +

    +

    + {library.name.replace('TanStack ', '')} +

    + {library.badge && ( + + {library.badge} + + )} + {isTopPick && ( + + Where to start + + )} +
    +

    + {library.tagline} +

    +

    + {library.description} +

    + +
    + {library.frameworks.slice(0, 5).map((fw) => ( + + ))} + {library.frameworks.length > 5 && ( + + + {library.frameworks.length - 5} more + + )} + + Open {library.name.replace('TanStack ', '')}{' '} + + +
    +
    +
    +
  • + ) +} + +function RelatedPostsBlock({ + items, +}: { + items: Array<{ + post: { slug: string; title: string; published: string; excerpt?: string } + lib: LibrarySlim + }> +}) { + return ( +
    + + From the team + +

    + Recent writing tagged with this category +

    +
      + {items.map(({ post, lib }) => ( +
    • + + + {lib.name.replace('TanStack ', '')} + +
      +

      + {post.title} +

      +

      + {formatPublishedDate(post.published)} +

      +
      + + +
    • + ))} +
    +
    + ) +} + +function SectionEyebrow({ children }: { children: React.ReactNode }) { + return ( +

    + {children} +

    + ) +} + +function FrameworkChip({ label }: { label: string }) { + return ( + + {label} + + ) +} diff --git a/src/components/stack/stack-categories.ts b/src/components/stack/stack-categories.ts new file mode 100644 index 00000000..0a9052b0 --- /dev/null +++ b/src/components/stack/stack-categories.ts @@ -0,0 +1,123 @@ +import { librariesByGroup, librariesGroupNamesMap } from '~/libraries' +import type { LibrarySlim } from '~/libraries' + +export type GroupId = keyof typeof librariesByGroup + +export type CategorySlug = + | 'framework' + | 'state' + | 'ui' + | 'performance' + | 'tooling' + +export const slugToGroup: Record = { + framework: 'framework', + state: 'state', + ui: 'headlessUI', + performance: 'performance', + tooling: 'tooling', +} + +export const groupToSlug: Record = { + framework: 'framework', + state: 'state', + headlessUI: 'ui', + performance: 'performance', + tooling: 'tooling', +} + +export const categorySlugs = Object.keys(slugToGroup) as CategorySlug[] + +export type CategoryMeta = { + slug: CategorySlug + groupId: GroupId + name: string + shortName: string + headline: string + intro: string + topPickId: string + /** Accent gradient classes for the hero / numbered chips. */ + accent: { from: string; to: string; text: string } +} + +export const categoryMeta: Record = { + framework: { + slug: 'framework', + groupId: 'framework', + name: librariesGroupNamesMap.framework, + shortName: 'Framework', + headline: 'The TanStack framework layer', + intro: + 'Type-safe routing and a full-stack framework built on top of it. Start small with Router, or go end-to-end with Start.', + topPickId: 'start', + accent: { + from: 'from-teal-500', + to: 'to-cyan-500', + text: 'text-cyan-600 dark:text-cyan-400', + }, + }, + state: { + slug: 'state', + groupId: 'state', + name: librariesGroupNamesMap.state, + shortName: 'Data & State', + headline: 'Data and state — without the ceremony', + intro: + 'Server state, async data, reactive stores, and an AI-aware layer on top. The libraries you reach for when an app needs to remember things, fetch things, and stay coherent across the screen.', + topPickId: 'query', + accent: { + from: 'from-cyan-500', + to: 'to-emerald-500', + text: 'text-cyan-600 dark:text-cyan-400', + }, + }, + ui: { + slug: 'ui', + groupId: 'headlessUI', + name: librariesGroupNamesMap.headlessUI, + shortName: 'UI & UX', + headline: 'Headless primitives for the surfaces users touch', + intro: + 'Tables, forms, keyboard shortcuts — owned by you, styled by you, validated by the compiler.', + topPickId: 'table', + accent: { + from: 'from-blue-500', + to: 'to-yellow-500', + text: 'text-blue-600 dark:text-blue-400', + }, + }, + performance: { + slug: 'performance', + groupId: 'performance', + name: librariesGroupNamesMap.performance, + shortName: 'Performance', + headline: 'Keep the long lists buttery, the noisy events tame', + intro: + 'Virtualisation, debouncing, throttling, batching — primitives that compose instead of one-off hooks.', + topPickId: 'virtual', + accent: { + from: 'from-purple-500', + to: 'to-lime-500', + text: 'text-purple-600 dark:text-purple-400', + }, + }, + tooling: { + slug: 'tooling', + groupId: 'tooling', + name: librariesGroupNamesMap.tooling, + shortName: 'Tooling', + headline: 'Devtools, scaffolds, and packaging defaults', + intro: + 'Take the boring decisions off your plate, so the interesting work stays interesting.', + topPickId: 'devtools', + accent: { + from: 'from-indigo-500', + to: 'to-orange-500', + text: 'text-indigo-600 dark:text-indigo-400', + }, + }, +} + +export function getCategoryLibraries(slug: CategorySlug): LibrarySlim[] { + return [...librariesByGroup[slugToGroup[slug]]] +} diff --git a/src/libraries/libraries.ts b/src/libraries/libraries.ts index f3d305fd..ceec0883 100644 --- a/src/libraries/libraries.ts +++ b/src/libraries/libraries.ts @@ -835,13 +835,15 @@ export const libraries: LibrarySlim[] = [ ] export const librariesByGroup = { - state: [start, router, query, db, store, ai], + framework: [start, router], + state: [query, db, store, ai], headlessUI: [table, form, hotkeys], performance: [virtual, pacer], tooling: [devtools, config, cli, intent], } export const librariesGroupNamesMap = { + framework: 'Framework', state: 'Data & State Management', headlessUI: 'UI & UX', performance: 'Performance', diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ca6a34ad..659d1246 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -49,6 +49,7 @@ import { Route as BlogIndexRouteImport } from './routes/blog.index' import { Route as AdminIndexRouteImport } from './routes/admin/index' import { Route as AccountIndexRouteImport } from './routes/account/index' import { Route as LibraryIdIndexRouteImport } from './routes/$libraryId/index' +import { Route as StackCategoryRouteImport } from './routes/stack.$category' import { Route as ShowcaseSubmitRouteImport } from './routes/showcase/submit' import { Route as ShowcaseIdRouteImport } from './routes/showcase/$id' import { Route as ShopSearchRouteImport } from './routes/shop.search' @@ -353,6 +354,11 @@ const LibraryIdIndexRoute = LibraryIdIndexRouteImport.update({ path: '/', getParentRoute: () => LibraryIdRouteRoute, } as any) +const StackCategoryRoute = StackCategoryRouteImport.update({ + id: '/stack/$category', + path: '/stack/$category', + getParentRoute: () => rootRouteImport, +} as any) const ShowcaseSubmitRoute = ShowcaseSubmitRouteImport.update({ id: '/showcase/submit', path: '/showcase/submit', @@ -953,6 +959,7 @@ export interface FileRoutesByFullPath { '/shop/search': typeof ShopSearchRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute + '/stack/$category': typeof StackCategoryRoute '/$libraryId/': typeof LibraryIdIndexRoute '/account/': typeof AccountIndexRoute '/admin/': typeof AdminIndexRoute @@ -1090,6 +1097,7 @@ export interface FileRoutesByTo { '/shop/search': typeof ShopSearchRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute + '/stack/$category': typeof StackCategoryRoute '/$libraryId': typeof LibraryIdIndexRoute '/account': typeof AccountIndexRoute '/admin': typeof AdminIndexRoute @@ -1234,6 +1242,7 @@ export interface FileRoutesById { '/shop/search': typeof ShopSearchRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute + '/stack/$category': typeof StackCategoryRoute '/$libraryId/': typeof LibraryIdIndexRoute '/account/': typeof AccountIndexRoute '/admin/': typeof AdminIndexRoute @@ -1381,6 +1390,7 @@ export interface FileRouteTypes { | '/shop/search' | '/showcase/$id' | '/showcase/submit' + | '/stack/$category' | '/$libraryId/' | '/account/' | '/admin/' @@ -1518,6 +1528,7 @@ export interface FileRouteTypes { | '/shop/search' | '/showcase/$id' | '/showcase/submit' + | '/stack/$category' | '/$libraryId' | '/account' | '/admin' @@ -1661,6 +1672,7 @@ export interface FileRouteTypes { | '/shop/search' | '/showcase/$id' | '/showcase/submit' + | '/stack/$category' | '/$libraryId/' | '/account/' | '/admin/' @@ -1790,6 +1802,7 @@ export interface RootRouteChildren { OauthTokenRoute: typeof OauthTokenRoute ShowcaseIdRoute: typeof ShowcaseIdRoute ShowcaseSubmitRoute: typeof ShowcaseSubmitRoute + StackCategoryRoute: typeof StackCategoryRoute ShowcaseIndexRoute: typeof ShowcaseIndexRoute StatsIndexRoute: typeof StatsIndexRoute ApiApplicationStarterResolveRoute: typeof ApiApplicationStarterResolveRoute @@ -2122,6 +2135,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LibraryIdIndexRouteImport parentRoute: typeof LibraryIdRouteRoute } + '/stack/$category': { + id: '/stack/$category' + path: '/stack/$category' + fullPath: '/stack/$category' + preLoaderRoute: typeof StackCategoryRouteImport + parentRoute: typeof rootRouteImport + } '/showcase/submit': { id: '/showcase/submit' path: '/showcase/submit' @@ -3109,6 +3129,7 @@ const rootRouteChildren: RootRouteChildren = { OauthTokenRoute: OauthTokenRoute, ShowcaseIdRoute: ShowcaseIdRoute, ShowcaseSubmitRoute: ShowcaseSubmitRoute, + StackCategoryRoute: StackCategoryRoute, ShowcaseIndexRoute: ShowcaseIndexRoute, StatsIndexRoute: StatsIndexRoute, ApiApplicationStarterResolveRoute: ApiApplicationStarterResolveRoute, diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 9e16b636..628f0a85 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -8,13 +8,14 @@ import { import discordImage from '~/images/discord-logo-white.svg' import { librariesByGroup, librariesGroupNamesMap, Library } from '~/libraries' +import { groupToSlug } from '~/components/stack/stack-categories' +import { twMerge } from 'tailwind-merge' import { NetlifyImage } from '~/components/NetlifyImage' import { TrustedByMarquee } from '~/components/TrustedByMarquee' import { ArrowRight, Code2, Layers, Shield, Zap, Play } from 'lucide-react' import { YouTubeIcon } from '~/components/icons/YouTubeIcon' import { Card } from '~/components/Card' -import LibraryCard from '~/components/LibraryCard' import { HomeApplicationStarter } from '~/components/home/HomeApplicationStarter' import { HomeDeferredSection } from '~/components/home/HomeDeferredSection' import { @@ -271,49 +272,35 @@ function Index() {

    - Open Source Libraries + Browse the stack

    +

    + Every TanStack library, organized by what it does. +

    - {Object.entries(librariesByGroup).map( - ([groupName, groupLibraries]) => ( -
    -

    - { - librariesGroupNamesMap[ - groupName as keyof typeof librariesGroupNamesMap - ] - } -

    - {/* Library Cards */} -
    - {groupLibraries.map((library, i: number) => { - return ( - - ) - })} -
    -
    - ), - )} +
    + {Object.entries(librariesByGroup).map( + ([groupName, groupLibraries]) => ( + + ), + )} +
    @@ -499,6 +486,54 @@ function Index() { ) } +function StackCategoryCard({ + groupId, + libraries, +}: { + groupId: keyof typeof librariesByGroup + libraries: Library[] +}) { + const groupName = librariesGroupNamesMap[groupId] + const categorySlug = groupToSlug[groupId] + + return ( + +

    + Category +

    +

    + {groupName} +

    +
      + {libraries.map((lib, i) => ( +
    1. + + {i + 1} + + + {lib.name.replace('TanStack ', '')} + +
    2. + ))} +
    + + Browse {groupName.toLowerCase()} + + + + ) +} + function OpenSourceUnderline() { return ( diff --git a/src/routes/stack.$category.tsx b/src/routes/stack.$category.tsx new file mode 100644 index 00000000..a4a5a0e9 --- /dev/null +++ b/src/routes/stack.$category.tsx @@ -0,0 +1,36 @@ +import { createFileRoute, notFound } from '@tanstack/react-router' + +import { CategoryArticle } from '~/components/stack/CategoryArticle' +import { + categoryMeta, + categorySlugs, + type CategorySlug, +} from '~/components/stack/stack-categories' +import { seo } from '~/utils/seo' + +function isCategorySlug(value: string): value is CategorySlug { + return (categorySlugs as readonly string[]).includes(value) +} + +export const Route = createFileRoute('/stack/$category')({ + loader: ({ params }) => { + if (!isCategorySlug(params.category)) { + throw notFound() + } + return { category: params.category, meta: categoryMeta[params.category] } + }, + head: ({ loaderData }) => ({ + meta: seo({ + title: loaderData + ? `${loaderData.meta.shortName} — TanStack libraries` + : 'TanStack libraries', + description: loaderData?.meta.intro, + }), + }), + component: StackCategoryPage, +}) + +function StackCategoryPage() { + const { category } = Route.useLoaderData() + return +}