add fumadocs

This commit is contained in:
2025-04-06 15:29:26 +02:00
parent d1974db5ed
commit d6c1a29771
27 changed files with 1176 additions and 157 deletions

View File

@@ -0,0 +1,7 @@
import { baseOptions } from '@/app/layout.config'
import { HomeLayout } from 'fumadocs-ui/layouts/home'
import type { ReactNode } from 'react'
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions}>{children}</HomeLayout>
}

View File

@@ -20,110 +20,6 @@ import Link from 'next/link'
export default function Home() {
return (
<div className='flex min-h-screen flex-col'>
<header className='sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60'>
<div className='container mx-auto flex h-16 items-center space-x-4 sm:justify-between sm:space-x-0'>
<div className='flex gap-6 md:gap-10'>
<Link href='/' className='flex items-center space-x-2'>
<img
src={'/logo.png'}
alt={'Balatro Multiplayer'}
className={'size-8'}
/>
<span className='inline-block font-bold'>
Balatro Multiplayer
</span>
</Link>
<nav className='hidden gap-6 md:flex'>
<Link
href='/docs'
className='flex items-center font-medium text-muted-foreground text-sm transition-colors hover:text-primary'
>
<BookOpen className='mr-1 h-4 w-4' />
Documentation
</Link>
<Link
href='/leaderboards'
className='flex items-center font-medium text-muted-foreground text-sm transition-colors hover:text-primary'
>
<Trophy className='mr-1 h-4 w-4' />
Leaderboards
</Link>
<Link
href='/about'
className='flex items-center font-medium text-muted-foreground text-sm transition-colors hover:text-primary'
>
<Info className='mr-1 h-4 w-4' />
About
</Link>
<Link
href='/credits'
className='flex items-center font-medium text-muted-foreground text-sm transition-colors hover:text-primary'
>
<Award className='mr-1 h-4 w-4' />
Credits
</Link>
</nav>
</div>
<div className='md:hidden'>
<Sheet>
<SheetTrigger asChild>
<Button
variant='ghost'
className='px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0'
>
<Menu className='h-6 w-6' />
<span className='sr-only'>Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side='left'>
<Link href='/' className='flex items-center space-x-2'>
<img
src={'/logo.png'}
alt={'Balatro Multiplayer'}
className={'size-8'}
/>
<span className='inline-block font-bold'>
Balatro Multiplayer
</span>
</Link>
<div className='mt-6 flex flex-col space-y-3'>
<Link
href='/docs'
className='flex items-center font-medium text-muted-foreground text-sm transition-colors hover:text-primary'
>
<BookOpen className='mr-2 h-4 w-4' />
Documentation
</Link>
<Link
href='/leaderboards'
className='flex items-center font-medium text-muted-foreground text-sm transition-colors hover:text-primary'
>
<Trophy className='mr-2 h-4 w-4' />
Leaderboards
</Link>
<Link
href='/about'
className='flex items-center font-medium text-muted-foreground text-sm transition-colors hover:text-primary'
>
<Info className='mr-2 h-4 w-4' />
About
</Link>
<Link
href='/credits'
className='flex items-center font-medium text-muted-foreground text-sm transition-colors hover:text-primary'
>
<Award className='mr-2 h-4 w-4' />
Credits
</Link>
</div>
</SheetContent>
</Sheet>
</div>
<div className='flex flex-1 items-center justify-end space-x-4'>
<ModeToggle />
</div>
</div>
</header>
<main className='flex-1'>
<section className='space-y-6 pt-6 pb-8 md:pt-10 md:pb-12 lg:py-32'>
<div className='container mx-auto flex flex-col items-center gap-4 text-center'>

View File

@@ -1,8 +1,8 @@
import { UserInfo } from '@/app/players/[id]/user'
import { auth } from '@/server/auth'
import { RANKED_CHANNEL, VANILLA_CHANNEL } from '@/shared/constants'
import { HydrateClient, api } from '@/trpc/server'
import { Suspense } from 'react'
import { UserInfo } from './user'
export default async function PlayerPage({
params,

View File

@@ -33,7 +33,6 @@ import {
Filter,
IceCreamCone,
InfoIcon,
MinusCircle,
ShieldHalf,
Star,
Trophy,

View File

@@ -0,0 +1,170 @@
import { ThemeToggle } from 'fumadocs-ui/components/layout/theme-toggle'
import type { HomeLayoutProps } from 'fumadocs-ui/layouts/home'
import type { LinkItemType } from 'fumadocs-ui/layouts/links'
import { replaceOrDefault } from 'fumadocs-ui/layouts/shared'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { Fragment } from 'react'
import { Menu, MenuContent, MenuLinkItem, MenuTrigger } from './home/menu'
import {
NavbarLink,
NavbarMenu,
NavbarMenuContent,
NavbarMenuLink,
NavbarMenuTrigger,
} from './home/navbar'
import { Navbar } from './home/navbar'
import { LargeSearchToggle, SearchToggle } from './search-toggle'
export function Header({
nav: { enableSearch = true, ...nav } = {},
finalLinks,
themeSwitch,
}: HomeLayoutProps & {
finalLinks: LinkItemType[]
}) {
const navItems = finalLinks.filter((item) =>
['nav', 'all'].includes(item.on ?? 'all')
)
const menuItems = finalLinks.filter((item) =>
['menu', 'all'].includes(item.on ?? 'all')
)
return (
<Navbar>
<Link
href={nav.url ?? '/'}
className='inline-flex items-center gap-2.5 font-semibold'
>
{nav.title}
</Link>
{nav.children}
<ul className='flex flex-row items-center gap-2 px-6 max-sm:hidden'>
{navItems
.filter((item) => !isSecondary(item))
.map((item, i) => (
<NavbarLinkItem key={i} item={item} className='text-sm' />
))}
</ul>
<div className='flex flex-1 flex-row items-center justify-end gap-1.5'>
{enableSearch ? (
<>
<SearchToggle className='lg:hidden' hideIfDisabled />
<LargeSearchToggle
className='w-full max-w-[240px] max-lg:hidden'
hideIfDisabled
/>
</>
) : null}
{replaceOrDefault(
themeSwitch,
<ThemeToggle className='max-lg:hidden' mode={themeSwitch?.mode} />
)}
</div>
<ul className='flex flex-row items-center'>
{navItems.filter(isSecondary).map((item, i) => (
<NavbarLinkItem
key={i}
item={item}
className='-me-1.5 max-lg:hidden'
/>
))}
<Menu className='lg:hidden'>
<MenuTrigger
aria-label='Toggle Menu'
className='group -me-2'
enableHover={nav.enableHoverToOpen}
>
<ChevronDown className='size-3 transition-transform duration-300 group-data-[state=open]:rotate-180' />
</MenuTrigger>
<MenuContent className='sm:flex-row sm:items-center sm:justify-end'>
{menuItems
.filter((item) => !isSecondary(item))
.map((item, i) => (
<MenuLinkItem key={i} item={item} className='sm:hidden' />
))}
<div className='-ms-1.5 flex flex-row items-center gap-1.5 max-sm:mt-2'>
{menuItems.filter(isSecondary).map((item, i) => (
<MenuLinkItem key={i} item={item} className='-me-1.5' />
))}
<div role='separator' className='flex-1' />
{replaceOrDefault(
themeSwitch,
<ThemeToggle mode={themeSwitch?.mode} />
)}
</div>
</MenuContent>
</Menu>
</ul>
</Navbar>
)
}
function NavbarLinkItem({
item,
...props
}: {
item: LinkItemType
className?: string
}) {
if (item.type === 'custom') return <div {...props}>{item.children}</div>
if (item.type === 'menu') {
const children = item.items.map((child, j) => {
if (child.type === 'custom')
return <Fragment key={j}>{child.children}</Fragment>
const {
banner = child.icon ? (
<div className='w-fit rounded-md border bg-fd-muted p-1 [&_svg]:size-4'>
{child.icon}
</div>
) : null,
...rest
} = child.menu ?? {}
return (
<NavbarMenuLink key={j} href={child.url} {...rest}>
{rest.children ?? (
<>
{banner}
<p className='-mb-1 font-medium text-sm'>{child.text}</p>
{child.description ? (
<p className='text-[13px] text-fd-muted-foreground'>
{child.description}
</p>
) : null}
</>
)}
</NavbarMenuLink>
)
})
return (
<NavbarMenu>
<NavbarMenuTrigger {...props}>
{item.url ? <Link href={item.url}>{item.text}</Link> : item.text}
</NavbarMenuTrigger>
<NavbarMenuContent>{children}</NavbarMenuContent>
</NavbarMenu>
)
}
return (
<NavbarLink
{...props}
item={item}
variant={item.type}
aria-label={item.type === 'icon' ? item.label : undefined}
>
{item.type === 'icon' ? item.icon : item.text}
</NavbarLink>
)
}
function isSecondary(item: LinkItemType): boolean {
return (
('secondary' in item && item.secondary === true) || item.type === 'icon'
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import {
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuTrigger,
} from '@/components/ui/navigation-menu'
import { cn } from '@/lib/utils'
import { cva } from 'class-variance-authority'
import Link from 'fumadocs-core/link'
import { buttonVariants } from 'fumadocs-ui/components/ui/button'
import { BaseLinkItem, type LinkItemType } from 'fumadocs-ui/layouts/links'
import type { ComponentPropsWithoutRef } from 'react'
const menuItemVariants = cva('', {
variants: {
variant: {
main: 'inline-flex items-center gap-2 py-1.5 transition-colors hover:text-fd-popover-foreground/50 data-[active=true]:font-medium data-[active=true]:text-fd-primary [&_svg]:size-4',
icon: buttonVariants({
size: 'icon',
color: 'ghost',
}),
button: buttonVariants({
color: 'secondary',
className: 'gap-1.5 [&_svg]:size-4',
}),
},
},
defaultVariants: {
variant: 'main',
},
})
export function MenuLinkItem({
item,
...props
}: {
item: LinkItemType
className?: string
}) {
if (item.type === 'custom')
return <div className={cn('grid', props.className)}>{item.children}</div>
if (item.type === 'menu') {
const header = (
<>
{item.icon}
{item.text}
</>
)
return (
<div className={cn('mb-4 flex flex-col', props.className)}>
<p className='mb-1 text-fd-muted-foreground text-sm'>
{item.url ? (
<NavigationMenuLink asChild>
<Link href={item.url}>{header}</Link>
</NavigationMenuLink>
) : (
header
)}
</p>
{item.items.map((child, i) => (
<MenuLinkItem key={i} item={child} />
))}
</div>
)
}
return (
<NavigationMenuLink asChild>
<BaseLinkItem
item={item}
className={cn(
menuItemVariants({ variant: item.type }),
props.className
)}
aria-label={item.type === 'icon' ? item.label : undefined}
>
{item.icon}
{item.type === 'icon' ? undefined : item.text}
</BaseLinkItem>
</NavigationMenuLink>
)
}
export const Menu = NavigationMenuItem
export function MenuTrigger({
enableHover = false,
...props
}: ComponentPropsWithoutRef<typeof NavigationMenuTrigger> & {
/**
* Enable hover to trigger
*/
enableHover?: boolean
}) {
return (
<NavigationMenuTrigger
{...props}
onPointerMove={enableHover ? undefined : (e) => e.preventDefault()}
className={cn(
buttonVariants({
size: 'icon',
color: 'ghost',
}),
props.className
)}
>
{props.children}
</NavigationMenuTrigger>
)
}
export function MenuContent(
props: ComponentPropsWithoutRef<typeof NavigationMenuContent>
) {
return (
<NavigationMenuContent
{...props}
className={cn('flex flex-col p-4', props.className)}
>
{props.children}
</NavigationMenuContent>
)
}

View File

@@ -0,0 +1,137 @@
'use client'
import { cn } from '@/lib/utils'
import type {
NavigationMenuContentProps,
NavigationMenuTriggerProps,
} from '@radix-ui/react-navigation-menu'
import { type VariantProps, cva } from 'class-variance-authority'
import Link, { type LinkProps } from 'fumadocs-core/link'
import { buttonVariants } from 'fumadocs-ui/components/ui/button'
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
NavigationMenuViewport,
} from 'fumadocs-ui/components/ui/navigation-menu'
import { useNav } from 'fumadocs-ui/contexts/layout'
import { BaseLinkItem } from 'fumadocs-ui/layouts/links'
import { type ComponentProps, type HTMLAttributes, useState } from 'react'
const navItemVariants = cva(
'inline-flex items-center gap-1 p-2 text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground data-[active=true]:text-fd-primary [&_svg]:size-4'
)
export function Navbar(props: HTMLAttributes<HTMLElement>) {
const [value, setValue] = useState('')
const { isTransparent } = useNav()
return (
<NavigationMenu value={value} onValueChange={setValue} asChild>
<header
id='nd-nav'
{...props}
className={cn(
'-translate-x-1/2 fixed top-(--fd-banner-height) left-1/2 z-40 box-content w-full max-w-fd-container border-fd-foreground/10 border-b transition-colors lg:mt-2 lg:w-[calc(100%-1rem)] lg:rounded-2xl lg:border',
value.length > 0 ? 'shadow-lg' : 'shadow-sm',
(!isTransparent || value.length > 0) &&
'bg-fd-background/80 backdrop-blur-lg',
props.className
)}
>
<NavigationMenuList
className='flex h-14 w-full flex-row items-center px-4 lg:h-12'
asChild
>
<nav>{props.children}</nav>
</NavigationMenuList>
<NavigationMenuViewport />
</header>
</NavigationMenu>
)
}
export const NavbarMenu = NavigationMenuItem
export function NavbarMenuContent(props: NavigationMenuContentProps) {
return (
<NavigationMenuContent
{...props}
className={cn(
'grid grid-cols-1 gap-3 px-4 pb-4 md:grid-cols-2 lg:grid-cols-3',
props.className
)}
>
{props.children}
</NavigationMenuContent>
)
}
export function NavbarMenuTrigger(props: NavigationMenuTriggerProps) {
return (
<NavigationMenuTrigger
{...props}
className={cn(navItemVariants(), 'rounded-md', props.className)}
>
{props.children}
</NavigationMenuTrigger>
)
}
export function NavbarMenuLink(props: LinkProps) {
return (
<NavigationMenuLink asChild>
<Link
{...props}
className={cn(
'flex flex-col gap-2 rounded-lg border bg-fd-card p-3 transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground',
props.className
)}
>
{props.children}
</Link>
</NavigationMenuLink>
)
}
const linkVariants = cva('', {
variants: {
variant: {
main: navItemVariants(),
button: buttonVariants({
color: 'secondary',
className: 'gap-1.5 [&_svg]:size-4',
}),
icon: buttonVariants({
color: 'ghost',
size: 'icon',
}),
},
},
defaultVariants: {
variant: 'main',
},
})
export function NavbarLink({
item,
variant,
...props
}: ComponentProps<typeof BaseLinkItem> & VariantProps<typeof linkVariants>) {
return (
<NavigationMenuItem>
<NavigationMenuLink asChild>
<BaseLinkItem
{...props}
item={item}
className={cn(linkVariants({ variant }), props.className)}
>
{props.children}
</BaseLinkItem>
</NavigationMenuLink>
</NavigationMenuItem>
)
}

View File

@@ -0,0 +1,74 @@
'use client'
import { type ButtonProps, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useSearchContext } from 'fumadocs-ui/contexts/search'
import { SearchIcon } from 'lucide-react'
import type { ButtonHTMLAttributes } from 'react'
export function SearchToggle({
hideIfDisabled,
size = 'icon',
variant = 'ghost',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> &
ButtonProps & {
hideIfDisabled?: boolean
}) {
const { setOpenSearch, enabled } = useSearchContext()
if (hideIfDisabled && !enabled) return null
return (
<button
type='button'
className={cn(
buttonVariants({
size,
variant,
}),
props.className
)}
data-search=''
aria-label='Open Search'
onClick={() => {
setOpenSearch(true)
}}
>
<SearchIcon />
</button>
)
}
export function LargeSearchToggle({
hideIfDisabled,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
hideIfDisabled?: boolean
}) {
const { enabled, hotKey, setOpenSearch } = useSearchContext()
if (hideIfDisabled && !enabled) return null
return (
<button
type='button'
data-search-full=''
{...props}
className={cn(
'inline-flex items-center gap-2 rounded-full border bg-fd-secondary/50 p-1.5 text-fd-muted-foreground text-sm transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground',
props.className
)}
onClick={() => {
setOpenSearch(true)
}}
>
<SearchIcon className='ms-1 size-4' />
Search
<div className='ms-auto inline-flex gap-0.5'>
{hotKey.map((k, i) => (
<kbd key={i} className='rounded-md border bg-fd-background px-1.5'>
{k.display}
</kbd>
))}
</div>
</button>
)
}

View File

@@ -0,0 +1,4 @@
import { createFromSource } from 'fumadocs-core/search/server'
import { source } from '../../../../lib/source'
export const { GET } = createFromSource(source)

View File

@@ -0,0 +1,14 @@
import { generateOGImage } from 'fumadocs-ui/og'
import { metadataImage } from '../../../../lib/metadata'
export const GET = metadataImage.createAPI((page) => {
return generateOGImage({
title: page.data.title,
description: page.data.description,
site: 'My App',
})
})
export function generateStaticParams() {
return metadataImage.generateParams()
}

View File

@@ -0,0 +1,47 @@
import defaultMdxComponents from 'fumadocs-ui/mdx'
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page'
import { notFound } from 'next/navigation'
import { metadataImage } from '../../../../lib/metadata'
import { source } from '../../../../lib/source'
export default async function Page(props: {
params: Promise<{ slug?: string[] }>
}) {
const params = await props.params
const page = source.getPage(params.slug)
if (!page) notFound()
const MDX = page.data.body
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
)
}
export async function generateStaticParams() {
return source.generateParams()
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>
}) {
const params = await props.params
const page = source.getPage(params.slug)
if (!page) notFound()
return metadataImage.withImage(page.slugs, {
title: page.data.title,
description: page.data.description,
})
}

13
src/app/docs/layout.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { baseOptions } from '@/app/layout.config'
import { DocsLayout } from 'fumadocs-ui/layouts/notebook'
import type { ReactNode } from 'react'
import { source } from '../../../lib/source'
export default function Layout({ children }: { children: ReactNode }) {
console.log(baseOptions)
return (
<DocsLayout {...baseOptions} tree={source.pageTree}>
{children}
</DocsLayout>
)
}

41
src/app/layout.config.tsx Normal file
View File

@@ -0,0 +1,41 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'
import { Award, BookOpen, Info, Trophy } from 'lucide-react'
import { Header } from './_components/header'
const links = [
{
text: 'Documentation',
url: '/docs',
icon: <BookOpen />,
},
{
text: 'Leaderboards',
url: '/leaderboards',
icon: <Trophy />,
},
{
text: 'About',
url: '/about',
icon: <Info />,
},
{
text: 'Credits',
url: '/credits',
icon: <Award />,
},
]
const nav = {
title: (
<div className='flex items-center space-x-2'>
<img src={'/logo.png'} alt={'Balatro Multiplayer'} className={'size-8'} />
<span className='inline-block font-bold'>Balatro Multiplayer</span>
</div>
),
}
export const baseOptions: BaseLayoutProps = {
links,
nav: {
...nav,
component: <Header finalLinks={links} nav={nav} />,
},
}

View File

@@ -1,20 +1,17 @@
import '@/styles/globals.css'
import type {Metadata} from 'next'
import {Geist} from 'next/font/google'
import {MainHeader} from '@/components/header'
import {ThemeProvider} from '@/components/theme-provider'
import {TRPCReactProvider} from '@/trpc/react'
import {SessionProvider} from 'next-auth/react'
import {NextIntlClientProvider} from 'next-intl'
import {getLocale} from 'next-intl/server'
import { TRPCReactProvider } from '@/trpc/react'
import { RootProvider } from 'fumadocs-ui/provider'
import type { Metadata } from 'next'
import { SessionProvider } from 'next-auth/react'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale } from 'next-intl/server'
import PlausibleProvider from 'next-plausible'
import { Geist } from 'next/font/google'
export const metadata: Metadata = {
title: 'Balatro Multiplayer',
description: 'Unofficial (for now) stats for the Balatro Multiplayer Mod',
icons: [{rel: 'icon', url: '/favicon.ico'}],
icons: [{ rel: 'icon', url: '/favicon.ico' }],
}
const geist = Geist({
@@ -23,8 +20,8 @@ const geist = Geist({
})
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
children,
}: Readonly<{ children: React.ReactNode }>) {
const locale = await getLocale()
return (
<html
@@ -32,33 +29,25 @@ export default async function RootLayout({
className={`${geist.variable}`}
suppressHydrationWarning
>
<head>
<title/>
<PlausibleProvider
domain='balatromp.com'
customDomain={'https://plausible.balatromp.com'}
trackOutboundLinks
trackFileDownloads
selfHosted
/>
</head>
<body>
<TRPCReactProvider>
<NextIntlClientProvider>
<SessionProvider>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</SessionProvider>
</NextIntlClientProvider>
</TRPCReactProvider>
</body>
<head>
<title />
<PlausibleProvider
domain='balatromp.com'
customDomain={'https://plausible.balatromp.com'}
trackOutboundLinks
trackFileDownloads
selfHosted
/>
</head>
<body className={'flex min-h-screen flex-col'}>
<TRPCReactProvider>
<NextIntlClientProvider>
<SessionProvider>
<RootProvider>{children}</RootProvider>
</SessionProvider>
</NextIntlClientProvider>
</TRPCReactProvider>
</body>
</html>
)
}