diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 33bf3c8..a65e5ee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,6 +15,10 @@ jobs: with: fetch-depth: 0 + - uses: actions/setup-node@v5 + with: + node-version: "20" + - name: Setup bun uses: oven-sh/setup-bun@v2 @@ -22,12 +26,12 @@ jobs: uses: actions/cache@v4 with: path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-web-${{ hashFiles('bun.lockb') }} + key: ${{ runner.os }}-bun-web-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-bun-web- - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Build website run: bun run build @@ -35,7 +39,7 @@ jobs: - name: Upload Build Artifact uses: actions/upload-pages-artifact@v3 with: - path: build + path: build/client deploy: name: Deploy to GitHub Pages diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml deleted file mode 100644 index d477959..0000000 --- a/.github/workflows/preview.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Deploy PR previews - -on: - pull_request: - types: - - opened - - reopened - - synchronize - - closed - -concurrency: preview-${{ github.ref }} - -jobs: - deploy-preview: - runs-on: ubuntu-latest - if: github.event.action != 'closed' - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup bun - uses: oven-sh/setup-bun@v2 - - - name: Cache bun dependencies - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-web-${{ hashFiles('bun.lockb') }} - restore-keys: | - ${{ runner.os }}-bun-web- - - - name: Install dependencies - run: bun install - - - name: Build website - run: bun run build - - - name: Deploy preview - uses: rossjrw/pr-preview-action@v1 - with: - source-dir: ./build/ diff --git a/.gitignore b/.gitignore index b2d6de3..6b6716d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ /build # Generated files -.docusaurus +.react-router .cache-loader # Misc diff --git a/.prettierignore b/.prettierignore index fbfa177..dbe41a6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,5 @@ -# Docusaurus build output -.docusaurus +# React router build output +.react-router # Node modules node_modules \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 49e759f..b9556ba 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,13 @@ { - "cSpell.enabled": true + "cSpell.enabled": true, + "cSpell.words": [ + "appstore", + "Healthcheck", + "homelab", + "homeserver", + "Runtipi", + "Tinyauth", + "TOTP", + "Traefik" + ] } diff --git a/app/app.css b/app/app.css new file mode 100644 index 0000000..50b3bc2 --- /dev/null +++ b/app/app.css @@ -0,0 +1,3 @@ +@import 'tailwindcss'; +@import 'fumadocs-ui/css/neutral.css'; +@import 'fumadocs-ui/css/preset.css'; diff --git a/app/cli.json b/app/cli.json new file mode 100644 index 0000000..1584439 --- /dev/null +++ b/app/cli.json @@ -0,0 +1,11 @@ +{ + "aliases": { + "uiDir": "./components/ui", + "componentsDir": "./components", + "blockDir": "./components", + "cssDir": "./styles", + "libDir": "./lib" + }, + "baseDir": "", + "commands": {} +} \ No newline at end of file diff --git a/app/components/card.tsx b/app/components/card.tsx new file mode 100644 index 0000000..83fb87d --- /dev/null +++ b/app/components/card.tsx @@ -0,0 +1,24 @@ +interface FeatureCardProps { + title: string; + description: string; + icon?: React.ReactNode; + children?: React.ReactNode; +} + +export const Card = ({ + title, + description, + icon, + children, +}: FeatureCardProps) => { + return ( +
+ {icon} +
+

{title}

+

{description}

+
+ {children} +
+ ); +}; diff --git a/app/components/discord.tsx b/app/components/discord.tsx new file mode 100644 index 0000000..20b2bcb --- /dev/null +++ b/app/components/discord.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from "react"; + +export function IcBaselineDiscord(props: SVGProps) { + return ( + + + + ); +} diff --git a/app/components/github.tsx b/app/components/github.tsx new file mode 100644 index 0000000..9be446d --- /dev/null +++ b/app/components/github.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from "react"; + +export function MdiGithub(props: SVGProps) { + return ( + + + + ); +} diff --git a/app/components/language-toggle.tsx b/app/components/language-toggle.tsx new file mode 100644 index 0000000..40a5443 --- /dev/null +++ b/app/components/language-toggle.tsx @@ -0,0 +1,68 @@ +'use client'; +import { type ButtonHTMLAttributes, type HTMLAttributes } from 'react'; +import { useI18n } from 'fumadocs-ui/contexts/i18n'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from './ui/popover'; +import { cn } from '../lib/cn'; +import { buttonVariants } from './ui/button'; + +export type LanguageSelectProps = ButtonHTMLAttributes; + +export function LanguageToggle(props: LanguageSelectProps): React.ReactElement { + const context = useI18n(); + if (!context.locales) throw new Error('Missing ``'); + + return ( + + + {props.children} + + +

+ {context.text.chooseLanguage} +

+ {context.locales.map((item) => ( + + ))} +
+
+ ); +} + +export function LanguageToggleText( + props: HTMLAttributes, +): React.ReactElement { + const context = useI18n(); + const text = context.locales?.find( + (item) => item.locale === context.locale, + )?.name; + + return {text}; +} diff --git a/app/components/layout/docs/client.tsx b/app/components/layout/docs/client.tsx new file mode 100644 index 0000000..3a7183a --- /dev/null +++ b/app/components/layout/docs/client.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Sidebar as SidebarIcon } from 'lucide-react'; +import { type ComponentProps } from 'react'; +import { cn } from '../../../lib/cn'; +import { buttonVariants } from '../../ui/button'; +import { useSidebar } from 'fumadocs-ui/contexts/sidebar'; +import { useNav } from 'fumadocs-ui/contexts/layout'; +import { SidebarCollapseTrigger } from '../../sidebar'; +import { SearchToggle } from '../../search-toggle'; + +export function Navbar(props: ComponentProps<'header'>) { + const { isTransparent } = useNav(); + + return ( +
+ {props.children} +
+ ); +} + +export function LayoutBody(props: ComponentProps<'main'>) { + const { collapsed } = useSidebar(); + + return ( +
+ {props.children} +
+ ); +} + +export function CollapsibleControl() { + const { collapsed } = useSidebar(); + + return ( +
+ + + + +
+ ); +} diff --git a/app/components/layout/docs/index.tsx b/app/components/layout/docs/index.tsx new file mode 100644 index 0000000..6a9e53f --- /dev/null +++ b/app/components/layout/docs/index.tsx @@ -0,0 +1,385 @@ +import type { PageTree } from "fumadocs-core/server"; +import { + type ComponentProps, + type HTMLAttributes, + type ReactNode, + useMemo, +} from "react"; +import { Languages, Sidebar as SidebarIcon } from "lucide-react"; +import { cn } from "../../../lib/cn"; +import { buttonVariants } from "../../ui/button"; +import { + Sidebar, + SidebarCollapseTrigger, + type SidebarComponents, + SidebarContent, + SidebarContentMobile, + SidebarFolder, + SidebarFolderContent, + SidebarFolderLink, + SidebarFolderTrigger, + SidebarFooter, + SidebarHeader, + SidebarItem, + SidebarPageTree, + type SidebarProps, + SidebarTrigger, + SidebarViewport, +} from "../../sidebar"; +import { type Option, RootToggle } from "../../root-toggle"; +import { + type BaseLayoutProps, + BaseLinkItem, + getLinks, + type IconItemType, + type LinkItemType, +} from "../shared/index"; +import { LanguageToggle, LanguageToggleText } from "../../language-toggle"; +import { CollapsibleControl, LayoutBody, Navbar } from "./client"; +import { TreeContextProvider } from "fumadocs-ui/contexts/tree"; +import { ThemeToggle } from "../../theme-toggle"; +import { NavProvider } from "fumadocs-ui/contexts/layout"; +import Link from "fumadocs-core/link"; +import { LargeSearchToggle, SearchToggle } from "../../search-toggle"; +import { HideIfEmpty } from "fumadocs-core/hide-if-empty"; +import { + getSidebarTabs, + type GetSidebarTabsOptions, +} from "fumadocs-ui/utils/get-sidebar-tabs"; +import { MdiGithub } from "@/components/github"; + +const githubLink: string = "https://github.com/steveiliop56/tinyauth"; + +export interface DocsLayoutProps extends BaseLayoutProps { + tree: PageTree.Root; + + sidebar?: SidebarOptions; + + /** + * Props for the `div` container + */ + containerProps?: HTMLAttributes; +} + +interface SidebarOptions + extends ComponentProps<"aside">, + Pick { + enabled?: boolean; + component?: ReactNode; + components?: Partial; + + /** + * Root Toggle options + */ + tabs?: Option[] | GetSidebarTabsOptions | false; + + banner?: ReactNode; + footer?: ReactNode; + + /** + * Support collapsing the sidebar on desktop mode + * + * @defaultValue true + */ + collapsible?: boolean; +} + +export function DocsLayout({ + nav: { transparentMode, ...nav } = {}, + sidebar: { + tabs: sidebarTabs, + enabled: sidebarEnabled = true, + ...sidebarProps + } = {}, + searchToggle = {}, + disableThemeSwitch = false, + themeSwitch = { enabled: !disableThemeSwitch }, + i18n = false, + children, + ...props +}: DocsLayoutProps) { + const tabs = useMemo(() => { + if (Array.isArray(sidebarTabs)) { + return sidebarTabs; + } + if (typeof sidebarTabs === "object") { + return getSidebarTabs(props.tree, sidebarTabs); + } + if (sidebarTabs !== false) { + return getSidebarTabs(props.tree); + } + return []; + }, [sidebarTabs, props.tree]); + const links = getLinks(props.links ?? [], props.githubUrl); + const sidebarVariables = cn( + "md:[--fd-sidebar-width:268px] lg:[--fd-sidebar-width:286px]" + ); + + function sidebar() { + const { + footer, + banner, + collapsible = true, + component, + components, + defaultOpenLevel, + prefetch, + ...rest + } = sidebarProps; + if (component) return component; + + const iconLinks = links.filter( + (item): item is IconItemType => item.type === "icon" + ); + + const viewport = ( + + {links + .filter((v) => v.type !== "icon") + .map((item, i, list) => ( + + ))} + + + ); + + const mobile = ( + + +
+
+ {iconLinks.map((item, i) => ( + + {item.icon} + + ))} +
+ {i18n ? ( + + + + + ) : null} + {themeSwitch.enabled !== false && + (themeSwitch.component ?? ( + + ))} + + + + + + +
+ {tabs.length > 0 && } + {banner} +
+ {viewport} + {footer} +
+ ); + + const content = ( + + +
+ Tinyauth + + {nav.title} + + {nav.children} + {collapsible && ( + + + + )} +
+ {searchToggle.enabled !== false && + (searchToggle.components?.lg ?? ( + + ))} + {tabs.length > 0 && } + + {banner} +
+ {viewport} + +
+ + + + {i18n ? ( + + + + ) : null} + {iconLinks.map((item, i) => ( + + {item.icon} + + ))} + {themeSwitch.enabled !== false && + (themeSwitch.component ?? ( + + ))} +
+ {footer} +
+
+ ); + + return ( + + {collapsible && } + {content} + + } + /> + ); + } + + return ( + + + {nav.enabled !== false && + (nav.component ?? ( + + Tinyauth + + {nav.title} + +
{nav.children}
+ {searchToggle.enabled !== false && + (searchToggle.components?.sm ?? ( + + ))} + {sidebarEnabled && ( + + + + )} +
+ ))} + + {sidebarEnabled && sidebar()} + {children} + +
+
+ ); +} + +function SidebarLinkItem({ + item, + ...props +}: { + item: Exclude; + className?: string; +}) { + if (item.type === "menu") + return ( + + {item.url ? ( + + {item.icon} + {item.text} + + ) : ( + + {item.icon} + {item.text} + + )} + + {item.items.map((child, i) => ( + + ))} + + + ); + + if (item.type === "custom") return
{item.children}
; + + return ( + + {item.text} + + ); +} + +export { CollapsibleControl, Navbar, SidebarTrigger, type LinkItemType }; diff --git a/app/components/layout/docs/page-client.tsx b/app/components/layout/docs/page-client.tsx new file mode 100644 index 0000000..39d90df --- /dev/null +++ b/app/components/layout/docs/page-client.tsx @@ -0,0 +1,417 @@ +'use client'; + +import { + type ComponentProps, + Fragment, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import Link from 'fumadocs-core/link'; +import { cn } from '../../../lib/cn'; +import { useI18n } from 'fumadocs-ui/contexts/i18n'; +import { useTreeContext, useTreePath } from 'fumadocs-ui/contexts/tree'; +import type { PageTree } from 'fumadocs-core/server'; +import { createContext, usePathname } from 'fumadocs-core/framework'; +import { + type BreadcrumbOptions, + getBreadcrumbItemsFromPath, +} from 'fumadocs-core/breadcrumb'; +import { useNav } from 'fumadocs-ui/contexts/layout'; +import { isActive } from '../../../lib/is-active'; +import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '../../ui/collapsible'; +import { useSidebar } from 'fumadocs-ui/contexts/sidebar'; +import { useTOCItems } from '../../ui/toc'; +import { useActiveAnchor } from 'fumadocs-core/toc'; + +const TocPopoverContext = createContext<{ + open: boolean; + setOpen: (open: boolean) => void; +}>('TocPopoverContext'); + +export function PageTOCPopoverTrigger(props: ComponentProps<'button'>) { + const { text } = useI18n(); + const { open } = TocPopoverContext.use(); + const items = useTOCItems(); + const active = useActiveAnchor(); + const selected = useMemo( + () => items.findIndex((item) => active === item.url.slice(1)), + [items, active], + ); + const path = useTreePath().at(-1); + const showItem = selected !== -1 && !open; + + return ( + + + + + {path?.name ?? text.toc} + + + {items[selected]?.title} + + + + + ); +} + +interface ProgressCircleProps + extends Omit, 'strokeWidth'> { + value: number; + strokeWidth?: number; + size?: number; + min?: number; + max?: number; +} + +function clamp(input: number, min: number, max: number): number { + if (input < min) return min; + if (input > max) return max; + return input; +} + +function ProgressCircle({ + value, + strokeWidth = 2, + size = 24, + min = 0, + max = 100, + ...restSvgProps +}: ProgressCircleProps) { + const normalizedValue = clamp(value, min, max); + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const progress = (normalizedValue / max) * circumference; + const circleProps = { + cx: size / 2, + cy: size / 2, + r: radius, + fill: 'none', + strokeWidth, + }; + + return ( + + + + + ); +} + +export function PageTOCPopoverContent(props: ComponentProps<'div'>) { + return ( + + {props.children} + + ); +} + +export function PageTOCPopover(props: ComponentProps<'div'>) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const { collapsed } = useSidebar(); + const { isTransparent } = useNav(); + + const onClick = useEffectEvent((e: Event) => { + if (!open) return; + + if (ref.current && !ref.current.contains(e.target as HTMLElement)) + setOpen(false); + }); + + useEffect(() => { + window.addEventListener('click', onClick); + + return () => { + window.removeEventListener('click', onClick); + }; + }, [onClick]); + + return ( + ({ + open, + setOpen, + }), + [setOpen, open], + )} + > + +
+ {props.children} +
+
+
+ ); +} + +export function PageLastUpdate({ + date: value, + ...props +}: Omit, 'children'> & { date: Date | string }) { + const { text } = useI18n(); + const [date, setDate] = useState(''); + + useEffect(() => { + // to the timezone of client + setDate(new Date(value).toLocaleDateString()); + }, [value]); + + return ( +

+ {text.lastUpdate} {date} +

+ ); +} + +type Item = Pick; +export interface FooterProps extends ComponentProps<'div'> { + /** + * Items including information for the next and previous page + */ + items?: { + previous?: Item; + next?: Item; + }; +} + +function scanNavigationList(tree: PageTree.Node[]) { + const list: PageTree.Item[] = []; + + tree.forEach((node) => { + if (node.type === 'folder') { + if (node.index) { + list.push(node.index); + } + + list.push(...scanNavigationList(node.children)); + return; + } + + if (node.type === 'page' && !node.external) { + list.push(node); + } + }); + + return list; +} + +const listCache = new Map(); + +export function PageFooter({ items, ...props }: FooterProps) { + const { root } = useTreeContext(); + const pathname = usePathname(); + + const { previous, next } = useMemo(() => { + if (items) return items; + + const cached = listCache.get(root.$id); + const list = cached ?? scanNavigationList(root.children); + listCache.set(root.$id, list); + + const idx = list.findIndex((item) => isActive(item.url, pathname, false)); + + if (idx === -1) return {}; + return { + previous: list[idx - 1], + next: list[idx + 1], + }; + }, [items, pathname, root]); + + return ( +
+ {previous ? : null} + {next ? : null} +
+ ); +} + +function FooterItem({ item, index }: { item: Item; index: 0 | 1 }) { + const { text } = useI18n(); + const Icon = index === 0 ? ChevronLeft : ChevronRight; + + return ( + +
+ +

{item.name}

+
+

+ {item.description ?? (index === 0 ? text.previousPage : text.nextPage)} +

+ + ); +} + +export type BreadcrumbProps = BreadcrumbOptions & ComponentProps<'div'>; + +export function PageBreadcrumb({ + includeRoot, + includeSeparator, + includePage, + ...props +}: BreadcrumbProps) { + const path = useTreePath(); + const { root } = useTreeContext(); + const items = useMemo(() => { + return getBreadcrumbItemsFromPath(root, path, { + includePage, + includeSeparator, + includeRoot, + }); + }, [includePage, includeRoot, includeSeparator, path, root]); + + if (items.length === 0) return null; + + return ( +
+ {items.map((item, i) => { + const className = cn( + 'truncate', + i === items.length - 1 && 'text-fd-primary font-medium', + ); + + return ( + + {i !== 0 && } + {item.url ? ( + + {item.name} + + ) : ( + {item.name} + )} + + ); + })} +
+ ); +} + +export function PageTOC(props: ComponentProps<'div'>) { + const { collapsed } = useSidebar(); + const offset = collapsed ? '0px' : 'var(--fd-layout-offset)'; + + return ( +
+
+ {props.children} +
+
+ ); +} diff --git a/app/components/layout/docs/page.tsx b/app/components/layout/docs/page.tsx new file mode 100644 index 0000000..25b4219 --- /dev/null +++ b/app/components/layout/docs/page.tsx @@ -0,0 +1,103 @@ +import { type ComponentProps } from "react"; +import { cn } from "../../../lib/cn"; +import { + type BreadcrumbProps, + type FooterProps, + PageBreadcrumb, + PageFooter, + PageLastUpdate, + PageTOC, + PageTOCPopover, + PageTOCPopoverContent, + PageTOCPopoverTrigger, +} from "./page-client"; +import { TOCItems, TOCProvider, TOCScrollArea } from "../../ui/toc"; +import { Text } from "lucide-react"; +import { I18nLabel } from "fumadocs-ui/contexts/i18n"; +import ClerkTOCItems from "../../ui/toc-clerk"; +import type { AnchorProviderProps } from "fumadocs-core/toc"; + +export function PageTOCTitle(props: ComponentProps<"h2">) { + return ( +

+ + +

+ ); +} + +export function PageTOCItems({ + variant = "normal", + ...props +}: ComponentProps<"div"> & { variant?: "clerk" | "normal" }) { + return ( + + {variant === "clerk" ? : } + + ); +} + +export function PageTOCPopoverItems({ + variant = "normal", + ...props +}: ComponentProps<"div"> & { variant?: "clerk" | "normal" }) { + return ( + + {variant === "clerk" ? : } + + ); +} + +export function PageArticle(props: ComponentProps<"article">) { + return ( +
+ {props.children} +
+ ); +} + +export interface RootProps extends ComponentProps<"div"> { + toc?: Omit | false; +} + +export function PageRoot({ toc = false, children, ...props }: RootProps) { + const content = ( +
+ {children} +
+ ); + + if (toc) return {content}; + return content; +} + +export { + PageBreadcrumb, + PageFooter, + PageLastUpdate, + PageTOC, + PageTOCPopover, + PageTOCPopoverTrigger, + PageTOCPopoverContent, + type FooterProps, + type BreadcrumbProps, +}; diff --git a/app/components/layout/home/index.tsx b/app/components/layout/home/index.tsx new file mode 100644 index 0000000..8cce0f0 --- /dev/null +++ b/app/components/layout/home/index.tsx @@ -0,0 +1,275 @@ +import { Fragment, type HTMLAttributes, useMemo } from "react"; +import { cn } from "../../../lib/cn"; +import { + type BaseLayoutProps, + getLinks, + type LinkItemType, + type NavOptions, +} from "../shared/index"; +import { NavProvider } from "fumadocs-ui/contexts/layout"; +import { + Navbar, + NavbarLink, + NavbarMenu, + NavbarMenuContent, + NavbarMenuLink, + NavbarMenuTrigger, +} from "./navbar"; +import { LargeSearchToggle, SearchToggle } from "../../search-toggle"; +import { ThemeToggle } from "../../theme-toggle"; +import { LanguageToggle, LanguageToggleText } from "../../language-toggle"; +import { ChevronDown, Languages } from "lucide-react"; +import Link from "fumadocs-core/link"; +import { Menu, MenuContent, MenuLinkItem, MenuTrigger } from "./menu"; +import { buttonVariants } from "../../ui/button"; +import { MdiGithub } from "@/components/github"; + +const githubLink: string = "https://github.com/steveiliop56/tinyauth"; + +export interface HomeLayoutProps extends BaseLayoutProps { + nav?: Partial< + NavOptions & { + /** + * Open mobile menu when hovering the trigger + */ + enableHoverToOpen?: boolean; + } + >; +} + +export function HomeLayout( + props: HomeLayoutProps & HTMLAttributes +) { + const { + nav = {}, + links, + githubUrl, + i18n, + disableThemeSwitch = false, + themeSwitch = { enabled: !disableThemeSwitch }, + searchToggle, + ...rest + } = props; + + return ( + +
+ {nav.enabled !== false && + (nav.component ?? ( +
+ ))} + {props.children} +
+
+ ); +} + +export function Header({ + nav = {}, + i18n = false, + links, + githubUrl, + themeSwitch = {}, + searchToggle = {}, +}: HomeLayoutProps) { + const finalLinks = useMemo( + () => getLinks(links, githubUrl), + [links, githubUrl] + ); + + const navItems = finalLinks.filter((item) => + ["nav", "all"].includes(item.on ?? "all") + ); + const menuItems = finalLinks.filter((item) => + ["menu", "all"].includes(item.on ?? "all") + ); + + return ( + +
+ Tinyauth + + {nav.title} + +
+ {nav.children} +
    + {navItems + .filter((item) => !isSecondary(item)) + .map((item, i) => ( + + ))} +
+
+ {searchToggle.enabled !== false && + (searchToggle.components?.lg ?? ( + + ))} + {themeSwitch.enabled !== false && + (themeSwitch.component ?? )} + + + + {i18n ? ( + + + + ) : null} +
+ {navItems.filter(isSecondary).map((item, i) => ( + + ))} +
+
+
    + {searchToggle.enabled !== false && + (searchToggle.components?.sm ?? ( + + ))} + + + + + + {menuItems + .filter((item) => !isSecondary(item)) + .map((item, i) => ( + + ))} +
    + {menuItems.filter(isSecondary).map((item, i) => ( + + ))} +
    + {i18n ? ( + + + + + + ) : null} + {themeSwitch.enabled !== false && + (themeSwitch.component ?? ( + + ))} + + + +
    + +
    +
+
+ ); +} + +function NavbarLinkItem({ + item, + ...props +}: { + item: LinkItemType; + className?: string; +}) { + if (item.type === "custom") return
{item.children}
; + + if (item.type === "menu") { + const children = item.items.map((child, j) => { + if (child.type === "custom") { + return {child.children}; + } + + const { + banner = child.icon ? ( +
+ {child.icon} +
+ ) : null, + ...rest + } = child.menu ?? {}; + + return ( + + {rest.children ?? ( + <> + {banner} +

{child.text}

+

+ {child.description} +

+ + )} +
+ ); + }); + + return ( + + + {item.url ? ( + + {item.text} + + ) : ( + item.text + )} + + {children} + + ); + } + + return ( + + {item.type === "icon" ? item.icon : item.text} + + ); +} + +function isSecondary(item: LinkItemType): boolean { + if ("secondary" in item && item.secondary != null) return item.secondary; + + return item.type === "icon"; +} diff --git a/app/components/layout/home/menu.tsx b/app/components/layout/home/menu.tsx new file mode 100644 index 0000000..7748c34 --- /dev/null +++ b/app/components/layout/home/menu.tsx @@ -0,0 +1,121 @@ +'use client'; +import { BaseLinkItem, type LinkItemType } from '../shared/index'; +import { cn } from '../../../lib/cn'; +import { + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuTrigger, +} from '../../navigation-menu'; +import Link from 'fumadocs-core/link'; +import { cva } from 'class-variance-authority'; +import { buttonVariants } from '../../ui/button'; +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
{item.children}
; + + if (item.type === 'menu') { + const header = ( + <> + {item.icon} + {item.text} + + ); + + return ( +
+

+ {item.url ? ( + + + {header} + + + ) : ( + header + )} +

+ {item.items.map((child, i) => ( + + ))} +
+ ); + } + + return ( + + + {item.icon} + {item.type === 'icon' ? undefined : item.text} + + + ); +} + +export const Menu = NavigationMenuItem; + +export function MenuTrigger({ + enableHover = false, + ...props +}: ComponentPropsWithoutRef & { + /** + * Enable hover to trigger + */ + enableHover?: boolean; +}) { + return ( + e.preventDefault()} + > + {props.children} + + ); +} + +export function MenuContent( + props: ComponentPropsWithoutRef, +) { + return ( + + {props.children} + + ); +} diff --git a/app/components/layout/home/navbar.tsx b/app/components/layout/home/navbar.tsx new file mode 100644 index 0000000..64e81fa --- /dev/null +++ b/app/components/layout/home/navbar.tsx @@ -0,0 +1,136 @@ +"use client"; +import { type ComponentProps, useState } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import Link, { type LinkProps } from "fumadocs-core/link"; +import { cn } from "../../../lib/cn"; +import { BaseLinkItem } from "../shared/index"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + NavigationMenuViewport, +} from "../../navigation-menu"; +import { useNav } from "fumadocs-ui/contexts/layout"; +import type { + NavigationMenuContentProps, + NavigationMenuTriggerProps, +} from "@radix-ui/react-navigation-menu"; +import { buttonVariants } from "../../ui/button"; + +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: ComponentProps<"div">) { + const [value, setValue] = useState(""); + const { isTransparent } = useNav(); + + return ( + +
0 && "max-lg:shadow-lg max-lg:rounded-b-2xl", + (!isTransparent || value.length > 0) && "bg-fd-background/80", + props.className + )} + > + + + + + +
+
+ ); +} + +export const NavbarMenu = NavigationMenuItem; + +export function NavbarMenuContent(props: NavigationMenuContentProps) { + return ( + + {props.children} + + ); +} + +export function NavbarMenuTrigger(props: NavigationMenuTriggerProps) { + return ( + + {props.children} + + ); +} + +export function NavbarMenuLink(props: LinkProps) { + return ( + + + {props.children} + + + ); +} + +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 & VariantProps) { + return ( + + + + {props.children} + + + + ); +} diff --git a/app/components/layout/page.tsx b/app/components/layout/page.tsx new file mode 100644 index 0000000..cf5f9f5 --- /dev/null +++ b/app/components/layout/page.tsx @@ -0,0 +1,295 @@ +import { type ComponentProps, forwardRef, type ReactNode } from "react"; +import { cn } from "../../lib/cn"; +import { buttonVariants } from "../ui/button"; +import { Edit } from "lucide-react"; +import { I18nLabel } from "fumadocs-ui/contexts/i18n"; +import { + type BreadcrumbProps, + type FooterProps, + PageArticle, + PageBreadcrumb, + PageFooter, + PageLastUpdate, + PageRoot, + PageTOC, + PageTOCItems, + PageTOCPopover, + PageTOCPopoverContent, + PageTOCPopoverItems, + PageTOCPopoverTrigger, + PageTOCTitle, +} from "./docs/page"; +import type { AnchorProviderProps } from "fumadocs-core/toc"; +import type { TOCItemType } from "fumadocs-core/server"; + +interface EditOnGitHubOptions + extends Omit, "href" | "children"> { + owner: string; + repo: string; + + /** + * SHA or ref (branch or tag) name. + * + * @defaultValue main + */ + sha?: string; + + /** + * File path in the repo + */ + path: string; +} + +interface BreadcrumbOptions extends BreadcrumbProps { + enabled: boolean; + component: ReactNode; + + /** + * Show the full path to the current page + * + * @defaultValue false + * @deprecated use `includePage` instead + */ + full?: boolean; +} + +interface FooterOptions extends FooterProps { + enabled: boolean; + component: ReactNode; +} + +export interface DocsPageProps { + toc?: TOCItemType[]; + tableOfContent?: Partial; + tableOfContentPopover?: Partial; + + /** + * Extend the page to fill all available space + * + * @defaultValue false + */ + full?: boolean; + + /** + * Replace or disable breadcrumb + */ + breadcrumb?: Partial; + + /** + * Footer navigation, you can disable it by passing `false` + */ + footer?: Partial; + + editOnGithub?: EditOnGitHubOptions; + lastUpdate?: Date | string | number; + + container?: ComponentProps<"div">; + article?: ComponentProps<"article">; + children?: ReactNode; +} + +type TableOfContentOptions = Pick & { + /** + * Custom content in TOC container, before the main TOC + */ + header?: ReactNode; + + /** + * Custom content in TOC container, after the main TOC + */ + footer?: ReactNode; + + enabled: boolean; + component: ReactNode; + + /** + * @defaultValue 'normal' + */ + style?: "normal" | "clerk"; +}; + +type TableOfContentPopoverOptions = Omit; + +export function DocsPage({ + editOnGithub, + breadcrumb: { + enabled: breadcrumbEnabled = true, + component: breadcrumb, + ...breadcrumbProps + } = {}, + footer = {}, + lastUpdate, + container, + full = false, + tableOfContentPopover: { + enabled: tocPopoverEnabled, + component: tocPopover, + ...tocPopoverOptions + } = {}, + tableOfContent: { + enabled: tocEnabled, + component: tocReplace, + ...tocOptions + } = {}, + toc = [], + article, + children, +}: DocsPageProps) { + // disable TOC on full mode, you can still enable it with `enabled` option. + tocEnabled ??= + !full && + (toc.length > 0 || + tocOptions.footer !== undefined || + tocOptions.header !== undefined); + + tocPopoverEnabled ??= + toc.length > 0 || + tocPopoverOptions.header !== undefined || + tocPopoverOptions.footer !== undefined; + + return ( + + {tocPopoverEnabled && + (tocPopover ?? ( + + + + {tocPopoverOptions.header} + + {tocPopoverOptions.footer} + + + ))} + + {breadcrumbEnabled && + (breadcrumb ?? )} + {children} +
+ {editOnGithub && ( + + )} + {lastUpdate && } +
+ {footer.enabled !== false && + (footer.component ?? )} +
+ {tocEnabled && + (tocReplace ?? ( + + {tocOptions.header} + + + {tocOptions.footer} + + ))} +
+ ); +} + +export function EditOnGitHub(props: ComponentProps<"a">) { + return ( + + {props.children ?? ( + <> + + + + )} + + ); +} + +/** + * Add typography styles + */ +export const DocsBody = forwardRef>( + (props, ref) => ( +
+ {props.children} +
+ ), +); + +DocsBody.displayName = "DocsBody"; + +export const DocsDescription = forwardRef< + HTMLParagraphElement, + ComponentProps<"p"> +>((props, ref) => { + // don't render if no description provided + if (props.children === undefined) return null; + + return ( +

+ {props.children} +

+ ); +}); + +DocsDescription.displayName = "DocsDescription"; + +export const DocsTitle = forwardRef>( + (props, ref) => { + return ( +

+ {props.children} +

+ ); + }, +); + +DocsTitle.displayName = "DocsTitle"; + +/** + * For separate MDX page + */ +export function withArticle(props: ComponentProps<"main">): ReactNode { + return ( +
+
{props.children}
+
+ ); +} diff --git a/app/components/layout/shared/client.tsx b/app/components/layout/shared/client.tsx new file mode 100644 index 0000000..d685015 --- /dev/null +++ b/app/components/layout/shared/client.tsx @@ -0,0 +1,30 @@ +'use client'; +import type { ComponentProps } from 'react'; +import { usePathname } from 'fumadocs-core/framework'; +import { isActive } from '../../../lib/is-active'; +import Link from 'fumadocs-core/link'; +import type { BaseLinkType } from './index'; + +export function BaseLinkItem({ + ref, + item, + ...props +}: Omit, 'href'> & { item: BaseLinkType }) { + const pathname = usePathname(); + const activeType = item.active ?? 'url'; + const active = + activeType !== 'none' && + isActive(item.url, pathname, activeType === 'nested-url'); + + return ( + + {props.children} + + ); +} diff --git a/app/components/layout/shared/index.tsx b/app/components/layout/shared/index.tsx new file mode 100644 index 0000000..c73090b --- /dev/null +++ b/app/components/layout/shared/index.tsx @@ -0,0 +1,182 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import type { NavProviderProps } from 'fumadocs-ui/contexts/layout'; +import type { I18nConfig } from 'fumadocs-core/i18n'; + +export interface NavOptions extends NavProviderProps { + enabled: boolean; + component: ReactNode; + + title?: ReactNode; + + /** + * Redirect url of title + * @defaultValue '/' + */ + url?: string; + + children?: ReactNode; +} + +export interface BaseLayoutProps { + themeSwitch?: { + enabled?: boolean; + component?: ReactNode; + mode?: 'light-dark' | 'light-dark-system'; + }; + + searchToggle?: Partial<{ + enabled: boolean; + components: Partial<{ + sm: ReactNode; + lg: ReactNode; + }>; + }>; + + /** + * Remove theme switcher component + * + * @deprecated Use `themeSwitch.enabled` instead. + */ + disableThemeSwitch?: boolean; + + /** + * I18n options + * + * @defaultValue false + */ + i18n?: boolean | I18nConfig; + + /** + * GitHub url + */ + githubUrl?: string; + + links?: LinkItemType[]; + /** + * Replace or disable navbar + */ + nav?: Partial; + + children?: ReactNode; +} + +interface BaseItem { + /** + * Restrict where the item is displayed + * + * @defaultValue 'all' + */ + on?: 'menu' | 'nav' | 'all'; +} + +export interface BaseLinkType extends BaseItem { + url: string; + /** + * When the item is marked as active + * + * @defaultValue 'url' + */ + active?: 'url' | 'nested-url' | 'none'; + external?: boolean; +} + +export interface MainItemType extends BaseLinkType { + type?: 'main'; + icon?: ReactNode; + text: ReactNode; + description?: ReactNode; +} + +export interface IconItemType extends BaseLinkType { + type: 'icon'; + /** + * `aria-label` of icon button + */ + label?: string; + icon: ReactNode; + text: ReactNode; + /** + * @defaultValue true + */ + secondary?: boolean; +} + +export interface ButtonItemType extends BaseLinkType { + type: 'button'; + icon?: ReactNode; + text: ReactNode; + /** + * @defaultValue false + */ + secondary?: boolean; +} + +export interface MenuItemType extends Partial { + type: 'menu'; + icon?: ReactNode; + text: ReactNode; + + items: ( + | (MainItemType & { + /** + * Options when displayed on navigation menu + */ + menu?: HTMLAttributes & { + banner?: ReactNode; + }; + }) + | CustomItemType + )[]; + + /** + * @defaultValue false + */ + secondary?: boolean; +} + +export interface CustomItemType extends BaseItem { + type: 'custom'; + /** + * @defaultValue false + */ + secondary?: boolean; + children: ReactNode; +} + +export type LinkItemType = + | MainItemType + | IconItemType + | ButtonItemType + | MenuItemType + | CustomItemType; + +/** + * Get Links Items with shortcuts + */ +export function getLinks( + links: LinkItemType[] = [], + githubUrl?: string, +): LinkItemType[] { + let result = links ?? []; + + if (githubUrl) + result = [ + ...result, + { + type: 'icon', + url: githubUrl, + text: 'Github', + label: 'GitHub', + icon: ( + + + + ), + external: true, + }, + ]; + + return result; +} + +export { BaseLinkItem } from './client'; diff --git a/app/components/mdx-components.tsx b/app/components/mdx-components.tsx new file mode 100644 index 0000000..d631e25 --- /dev/null +++ b/app/components/mdx-components.tsx @@ -0,0 +1,11 @@ +import defaultMdxComponents from "fumadocs-ui/mdx"; +import { Mermaid } from "@/components/mdx/mermaid"; +import type { MDXComponents } from "mdx/types"; + +export function getMDXComponents(components?: MDXComponents): MDXComponents { + return { + ...defaultMdxComponents, + Mermaid, + ...components, + }; +} diff --git a/app/components/mdx/mermaid.tsx b/app/components/mdx/mermaid.tsx new file mode 100644 index 0000000..807417c --- /dev/null +++ b/app/components/mdx/mermaid.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { use, useEffect, useId, useState } from "react"; +import { useTheme } from "next-themes"; + +export function Mermaid({ chart }: { chart: string }) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) return; + return ; +} + +const cache = new Map>(); + +function cachePromise( + key: string, + setPromise: () => Promise +): Promise { + const cached = cache.get(key); + if (cached) return cached as Promise; + + const promise = setPromise(); + cache.set(key, promise); + return promise; +} + +function MermaidContent({ chart }: { chart: string }) { + const id = useId(); + const { resolvedTheme } = useTheme(); + const { default: mermaid } = use( + cachePromise("mermaid", () => import("mermaid")) + ); + + mermaid.initialize({ + startOnLoad: false, + securityLevel: "loose", + fontFamily: "inherit", + themeCSS: "margin: 1.5rem auto 0;", + theme: resolvedTheme === "dark" ? "dark" : "default", + }); + + const { svg, bindFunctions } = use( + cachePromise(`${chart}-${resolvedTheme}`, () => { + return mermaid.render(id, chart.replaceAll("\\n", "\n")); + }) + ); + + return ( +
{ + if (container) bindFunctions?.(container); + }} + dangerouslySetInnerHTML={{ __html: svg }} + /> + ); +} diff --git a/app/components/navigation-menu.tsx b/app/components/navigation-menu.tsx new file mode 100644 index 0000000..9143b49 --- /dev/null +++ b/app/components/navigation-menu.tsx @@ -0,0 +1,80 @@ +'use client'; +import * as React from 'react'; +import * as Primitive from '@radix-ui/react-navigation-menu'; +import { cn } from '../lib/cn'; + +const NavigationMenu = Primitive.Root; + +const NavigationMenuList = Primitive.List; + +const NavigationMenuItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)); + +NavigationMenuItem.displayName = Primitive.NavigationMenuItem.displayName; + +const NavigationMenuTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)); +NavigationMenuTrigger.displayName = Primitive.Trigger.displayName; + +const NavigationMenuContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +NavigationMenuContent.displayName = Primitive.Content.displayName; + +const NavigationMenuLink = Primitive.Link; + +const NavigationMenuViewport = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ +
+)); +NavigationMenuViewport.displayName = Primitive.Viewport.displayName; + +export { + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuViewport, +}; diff --git a/app/components/root-toggle.tsx b/app/components/root-toggle.tsx new file mode 100644 index 0000000..02fed5d --- /dev/null +++ b/app/components/root-toggle.tsx @@ -0,0 +1,103 @@ +'use client'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import { type ComponentProps, type ReactNode, useMemo, useState } from 'react'; +import Link from 'fumadocs-core/link'; +import { usePathname } from 'fumadocs-core/framework'; +import { cn } from '../lib/cn'; +import { isTabActive } from '../lib/is-active'; +import { useSidebar } from 'fumadocs-ui/contexts/sidebar'; +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; +import type { SidebarTab } from 'fumadocs-ui/utils/get-sidebar-tabs'; + +export interface Option extends SidebarTab { + props?: ComponentProps<'a'>; +} + +export function RootToggle({ + options, + placeholder, + ...props +}: { + placeholder?: ReactNode; + options: Option[]; +} & ComponentProps<'button'>) { + const [open, setOpen] = useState(false); + const { closeOnRedirect } = useSidebar(); + const pathname = usePathname(); + + const selected = useMemo(() => { + return options.findLast((item) => isTabActive(item, pathname)); + }, [options, pathname]); + + const onClick = () => { + closeOnRedirect.current = false; + setOpen(false); + }; + + const item = selected ? ( + <> +
{selected.icon}
+
+

{selected.title}

+

+ {selected.description} +

+
+ + ) : ( + placeholder + ); + + return ( + + {item && ( + + {item} + + + )} + + {options.map((item) => { + const isActive = selected && item.url === selected.url; + if (!isActive && item.unlisted) return; + + return ( + +
+ {item.icon} +
+
+

{item.title}

+

+ {item.description} +

+
+ + + + ); + })} +
+
+ ); +} diff --git a/app/components/search-toggle.tsx b/app/components/search-toggle.tsx new file mode 100644 index 0000000..0952940 --- /dev/null +++ b/app/components/search-toggle.tsx @@ -0,0 +1,79 @@ +'use client'; +import type { ComponentProps } from 'react'; +import { Search } from 'lucide-react'; +import { useSearchContext } from 'fumadocs-ui/contexts/search'; +import { useI18n } from 'fumadocs-ui/contexts/i18n'; +import { cn } from '../lib/cn'; +import { type ButtonProps, buttonVariants } from './ui/button'; + +interface SearchToggleProps + extends Omit, 'color'>, + ButtonProps { + hideIfDisabled?: boolean; +} + +export function SearchToggle({ + hideIfDisabled, + size = 'icon-sm', + color = 'ghost', + ...props +}: SearchToggleProps) { + const { setOpenSearch, enabled } = useSearchContext(); + if (hideIfDisabled && !enabled) return null; + + return ( + + ); +} + +export function LargeSearchToggle({ + hideIfDisabled, + ...props +}: ComponentProps<'button'> & { + hideIfDisabled?: boolean; +}) { + const { enabled, hotKey, setOpenSearch } = useSearchContext(); + const { text } = useI18n(); + if (hideIfDisabled && !enabled) return null; + + return ( + + ); +} diff --git a/app/components/search.tsx b/app/components/search.tsx new file mode 100644 index 0000000..a88ce51 --- /dev/null +++ b/app/components/search.tsx @@ -0,0 +1,51 @@ +"use client"; +import { + SearchDialog, + SearchDialogClose, + SearchDialogContent, + SearchDialogHeader, + SearchDialogIcon, + SearchDialogInput, + SearchDialogList, + SearchDialogOverlay, + type SharedProps, +} from "fumadocs-ui/components/dialog/search"; +import { useDocsSearch } from "fumadocs-core/search/client"; +import { create } from "@orama/orama"; +import { useI18n } from "fumadocs-ui/contexts/i18n"; + +function initOrama() { + return create({ + schema: { _: "string" }, + // https://docs.orama.com/docs/orama-js/supported-languages + language: "english", + }); +} + +export default function DefaultSearchDialog(props: SharedProps) { + const { locale } = useI18n(); // (optional) for i18n + const { search, setSearch, query } = useDocsSearch({ + type: "static", + initOrama, + locale, + }); + + return ( + + + + + + + + + + + + ); +} diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx new file mode 100644 index 0000000..e34acb4 --- /dev/null +++ b/app/components/sidebar.tsx @@ -0,0 +1,559 @@ +"use client"; +import { ChevronDown, ExternalLink } from "lucide-react"; +import { usePathname } from "fumadocs-core/framework"; +import { + type ComponentProps, + createContext, + type FC, + Fragment, + type ReactNode, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import Link, { type LinkProps } from "fumadocs-core/link"; +import { useOnChange } from "fumadocs-core/utils/use-on-change"; +import { cn } from "../lib/cn"; +import { ScrollArea, ScrollViewport } from "./ui/scroll-area"; +import { isActive } from "../lib/is-active"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "./ui/collapsible"; +import { type ScrollAreaProps } from "@radix-ui/react-scroll-area"; +import { useSidebar } from "fumadocs-ui/contexts/sidebar"; +import { cva } from "class-variance-authority"; +import type { + CollapsibleContentProps, + CollapsibleTriggerProps, +} from "@radix-ui/react-collapsible"; +import type { PageTree } from "fumadocs-core/server"; +import { useTreeContext, useTreePath } from "fumadocs-ui/contexts/tree"; +import { useMediaQuery } from "fumadocs-core/utils/use-media-query"; +import { Presence } from "@radix-ui/react-presence"; + +export interface SidebarProps { + /** + * Open folders by default if their level is lower or equal to a specific level + * (Starting from 1) + * + * @defaultValue 0 + */ + defaultOpenLevel?: number; + + /** + * Prefetch links + * + * @defaultValue true + */ + prefetch?: boolean; + + /** + * Children to render + */ + Content: ReactNode; + + /** + * Alternative children for mobile + */ + Mobile?: ReactNode; +} + +interface InternalContext { + defaultOpenLevel: number; + prefetch: boolean; + level: number; +} + +const itemVariants = cva( + "mt-0.5 relative flex flex-row items-center gap-2 rounded-lg p-2 ps-(--sidebar-item-offset) text-start text-fd-muted-foreground [overflow-wrap:anywhere] [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + active: { + true: "bg-fd-primary/10 text-fd-primary", + false: + "transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none", + }, + }, + }, +); + +const Context = createContext(null); +const FolderContext = createContext<{ + open: boolean; + setOpen: React.Dispatch>; +} | null>(null); + +export function Sidebar({ + defaultOpenLevel = 0, + prefetch = true, + Mobile, + Content, +}: SidebarProps) { + const isMobile = useMediaQuery("(width < 768px)") ?? false; + const context = useMemo(() => { + return { + defaultOpenLevel, + prefetch, + level: 1, + }; + }, [defaultOpenLevel, prefetch]); + + return ( + + {isMobile && Mobile != null ? Mobile : Content} + + ); +} + +export function SidebarContent(props: ComponentProps<"aside">) { + const { collapsed } = useSidebar(); + const [hover, setHover] = useState(false); + const timerRef = useRef(0); + const closeTimeRef = useRef(0); + + useOnChange(collapsed, () => { + setHover(false); + closeTimeRef.current = Date.now() + 150; + }); + + return ( + + ); +} + +export function SidebarContentMobile({ + className, + children, + ...props +}: ComponentProps<"aside">) { + const { open, setOpen } = useSidebar(); + const state = open ? "open" : "closed"; + + return ( + <> + +
setOpen(false)} + /> + + + {({ present }) => ( + + )} + + + ); +} + +export function SidebarHeader(props: ComponentProps<"div">) { + return ( +
+ {props.children} +
+ ); +} + +export function SidebarFooter(props: ComponentProps<"div">) { + return ( +
+ {props.children} +
+ ); +} + +export function SidebarViewport(props: ScrollAreaProps) { + return ( + + + {props.children} + + + ); +} + +export function SidebarSeparator(props: ComponentProps<"p">) { + return ( +

+ {props.children} +

+ ); +} + +export function SidebarItem({ + icon, + ...props +}: LinkProps & { + icon?: ReactNode; +}) { + const pathname = usePathname(); + const active = + props.href !== undefined && isActive(props.href, pathname, false); + const { prefetch } = useInternalContext(); + + return ( + + {icon ?? (props.external ? : null)} + {props.children} + + ); +} + +export function SidebarFolder({ + defaultOpen = false, + ...props +}: ComponentProps<"div"> & { + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + + useOnChange(defaultOpen, (v) => { + if (v) setOpen(v); + }); + + return ( + + ({ open, setOpen }), [open])} + > + {props.children} + + + ); +} + +export function SidebarFolderTrigger({ + className, + ...props +}: CollapsibleTriggerProps) { + const { open } = useFolderContext(); + + return ( + + {props.children} + + + ); +} + +export function SidebarFolderLink(props: LinkProps) { + const { open, setOpen } = useFolderContext(); + const { prefetch } = useInternalContext(); + + const pathname = usePathname(); + const active = + props.href !== undefined && isActive(props.href, pathname, false); + + return ( + { + if ( + e.target instanceof Element && + e.target.matches("[data-icon], [data-icon] *") + ) { + setOpen(!open); + e.preventDefault(); + } else { + setOpen(active ? !open : true); + } + }} + prefetch={prefetch} + > + {props.children} + + + ); +} + +export function SidebarFolderContent(props: CollapsibleContentProps) { + const { level, ...ctx } = useInternalContext(); + + return ( + + ({ + ...ctx, + level: level + 1, + }), + [ctx, level], + )} + > + {props.children} + + + ); +} + +export function SidebarTrigger({ + children, + ...props +}: ComponentProps<"button">) { + const { setOpen } = useSidebar(); + + return ( + + ); +} + +export function SidebarCollapseTrigger(props: ComponentProps<"button">) { + const { collapsed, setCollapsed } = useSidebar(); + + return ( + + ); +} + +function useFolderContext() { + const ctx = useContext(FolderContext); + if (!ctx) throw new Error("Missing sidebar folder"); + + return ctx; +} + +function useInternalContext() { + const ctx = useContext(Context); + if (!ctx) throw new Error(" component required."); + + return ctx; +} + +export interface SidebarComponents { + Item: FC<{ item: PageTree.Item }>; + Folder: FC<{ item: PageTree.Folder; level: number; children: ReactNode }>; + Separator: FC<{ item: PageTree.Separator }>; +} + +/** + * Render sidebar items from page tree + */ +export function SidebarPageTree(props: { + components?: Partial; +}) { + const { root } = useTreeContext(); + + return useMemo(() => { + const { Separator, Item, Folder } = props.components ?? {}; + + function renderSidebarList( + items: PageTree.Node[], + level: number, + ): ReactNode[] { + return items.map((item, i) => { + if (item.type === "separator") { + if (Separator) return ; + return ( + + {item.icon} + {item.name} + + ); + } + + if (item.type === "folder") { + const children = renderSidebarList(item.children, level + 1); + + if (Folder) + return ( + + {children} + + ); + return ( + + {children} + + ); + } + + if (Item) return ; + return ( + + {item.name} + + ); + }); + } + + return ( + {renderSidebarList(root.children, 1)} + ); + }, [props.components, root]); +} + +function PageTreeFolder({ + item, + ...props +}: { + item: PageTree.Folder; + children: ReactNode; +}) { + const { defaultOpenLevel, level } = useInternalContext(); + const path = useTreePath(); + + return ( + = level) || path.includes(item) + } + > + {item.index ? ( + + {item.icon} + {item.name} + + ) : ( + + {item.icon} + {item.name} + + )} + {props.children} + + ); +} diff --git a/app/components/theme-toggle.tsx b/app/components/theme-toggle.tsx new file mode 100644 index 0000000..9ab62be --- /dev/null +++ b/app/components/theme-toggle.tsx @@ -0,0 +1,88 @@ +"use client"; +import { cva } from "class-variance-authority"; +import { Moon, Sun, Airplay } from "lucide-react"; +import { useTheme } from "next-themes"; +import { type HTMLAttributes, useLayoutEffect, useState } from "react"; +import { cn } from "../lib/cn"; + +const itemVariants = cva( + "size-6.5 rounded-full p-1.5 text-fd-muted-foreground cursor-pointer", + { + variants: { + active: { + true: "bg-fd-accent text-fd-accent-foreground", + false: + "text-fd-muted-foreground hover:opacity-80 hover:scale-105 transform-translate delay-100", + }, + }, + } +); + +const full = [ + ["light", Sun] as const, + ["dark", Moon] as const, + ["system", Airplay] as const, +]; + +export function ThemeToggle({ + className, + mode = "light-dark", + ...props +}: HTMLAttributes & { + mode?: "light-dark" | "light-dark-system"; +}) { + const { setTheme, theme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useLayoutEffect(() => { + setMounted(true); + }, []); + + const container = cn( + "inline-flex items-center rounded-full border p-1", + className + ); + + if (mode === "light-dark") { + const value = mounted ? resolvedTheme : null; + + return ( + + ); + } + + const value = mounted ? theme : null; + + return ( +
+ {full.map(([key, Icon]) => ( + + ))} +
+ ); +} diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx new file mode 100644 index 0000000..5fca2ad --- /dev/null +++ b/app/components/ui/button.tsx @@ -0,0 +1,28 @@ +import { cva, type VariantProps } from 'class-variance-authority'; + +const variants = { + primary: 'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80', + outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground', + ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground', + secondary: + 'border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground', +} as const; + +export const buttonVariants = cva( + 'inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none', + { + variants: { + variant: variants, + // fumadocs use `color` instead of `variant` + color: variants, + size: { + sm: 'gap-1 px-2 py-1.5 text-xs', + icon: 'p-1.5 [&_svg]:size-5', + 'icon-sm': 'p-1.5 [&_svg]:size-4.5', + 'icon-xs': 'p-1 [&_svg]:size-4', + }, + }, + }, +); + +export type ButtonProps = VariantProps; diff --git a/app/components/ui/collapsible.tsx b/app/components/ui/collapsible.tsx new file mode 100644 index 0000000..dbcf3f0 --- /dev/null +++ b/app/components/ui/collapsible.tsx @@ -0,0 +1,39 @@ +'use client'; +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; +import { forwardRef, useEffect, useState } from 'react'; +import { cn } from '../../lib/cn'; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ children, ...props }, ref) => { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return ( + + {children} + + ); +}); + +CollapsibleContent.displayName = + CollapsiblePrimitive.CollapsibleContent.displayName; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/app/components/ui/popover.tsx b/app/components/ui/popover.tsx new file mode 100644 index 0000000..e033f5d --- /dev/null +++ b/app/components/ui/popover.tsx @@ -0,0 +1,32 @@ +'use client'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import * as React from 'react'; +import { cn } from '../../lib/cn'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +const PopoverClose = PopoverPrimitive.PopoverClose; + +export { Popover, PopoverTrigger, PopoverContent, PopoverClose }; diff --git a/app/components/ui/scroll-area.tsx b/app/components/ui/scroll-area.tsx new file mode 100644 index 0000000..86cb150 --- /dev/null +++ b/app/components/ui/scroll-area.tsx @@ -0,0 +1,58 @@ +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; +import * as React from 'react'; +import { cn } from '../../lib/cn'; + +const ScrollArea = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + +)); + +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollViewport = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)); + +ScrollViewport.displayName = ScrollAreaPrimitive.Viewport.displayName; + +const ScrollBar = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName; + +export { ScrollArea, ScrollBar, ScrollViewport }; diff --git a/app/components/ui/toc-clerk.tsx b/app/components/ui/toc-clerk.tsx new file mode 100644 index 0000000..f8466ab --- /dev/null +++ b/app/components/ui/toc-clerk.tsx @@ -0,0 +1,179 @@ +'use client'; +import type { TOCItemType } from 'fumadocs-core/server'; +import * as Primitive from 'fumadocs-core/toc'; +import { type ComponentProps, useEffect, useRef, useState } from 'react'; +import { cn } from '../../lib/cn'; +import { TocThumb } from './toc-thumb'; +import { useTOCItems } from './toc'; +import { mergeRefs } from '../../lib/merge-refs'; +import { useI18n } from 'fumadocs-ui/contexts/i18n'; + +export default function ClerkTOCItems({ + ref, + className, + ...props +}: ComponentProps<'div'>) { + const containerRef = useRef(null); + const items = useTOCItems(); + const { text } = useI18n(); + + const [svg, setSvg] = useState<{ + path: string; + width: number; + height: number; + }>(); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + + function onResize(): void { + if (container.clientHeight === 0) return; + let w = 0, + h = 0; + const d: string[] = []; + for (let i = 0; i < items.length; i++) { + const element: HTMLElement | null = container.querySelector( + `a[href="#${items[i].url.slice(1)}"]`, + ); + if (!element) continue; + + const styles = getComputedStyle(element); + const offset = getLineOffset(items[i].depth) + 1, + top = element.offsetTop + parseFloat(styles.paddingTop), + bottom = + element.offsetTop + + element.clientHeight - + parseFloat(styles.paddingBottom); + + w = Math.max(offset, w); + h = Math.max(h, bottom); + + d.push(`${i === 0 ? 'M' : 'L'}${offset} ${top}`); + d.push(`L${offset} ${bottom}`); + } + + setSvg({ + path: d.join(' '), + width: w + 1, + height: h, + }); + } + + const observer = new ResizeObserver(onResize); + onResize(); + + observer.observe(container); + return () => { + observer.disconnect(); + }; + }, [items]); + + if (items.length === 0) + return ( +
+ {text.tocNoHeadings} +
+ ); + + return ( + <> + {svg ? ( +
`, + ) + }")`, + }} + > + +
+ ) : null} +
+ {items.map((item, i) => ( + + ))} +
+ + ); +} + +function getItemOffset(depth: number): number { + if (depth <= 2) return 14; + if (depth === 3) return 26; + return 36; +} + +function getLineOffset(depth: number): number { + return depth >= 3 ? 10 : 0; +} + +function TOCItem({ + item, + upper = item.depth, + lower = item.depth, +}: { + item: TOCItemType; + upper?: number; + lower?: number; +}) { + const offset = getLineOffset(item.depth), + upperOffset = getLineOffset(upper), + lowerOffset = getLineOffset(lower); + + return ( + + {offset !== upperOffset ? ( + + + + ) : null} +
+ {item.title} + + ); +} diff --git a/app/components/ui/toc-thumb.tsx b/app/components/ui/toc-thumb.tsx new file mode 100644 index 0000000..bdced31 --- /dev/null +++ b/app/components/ui/toc-thumb.tsx @@ -0,0 +1,73 @@ +import { type HTMLAttributes, type RefObject, useEffect, useRef } from 'react'; +import * as Primitive from 'fumadocs-core/toc'; +import { useOnChange } from 'fumadocs-core/utils/use-on-change'; +import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event'; + +export type TOCThumb = [top: number, height: number]; + +function calc(container: HTMLElement, active: string[]): TOCThumb { + if (active.length === 0 || container.clientHeight === 0) { + return [0, 0]; + } + + let upper = Number.MAX_VALUE, + lower = 0; + + for (const item of active) { + const element = container.querySelector(`a[href="#${item}"]`); + if (!element) continue; + + const styles = getComputedStyle(element); + upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop)); + lower = Math.max( + lower, + element.offsetTop + + element.clientHeight - + parseFloat(styles.paddingBottom), + ); + } + + return [upper, lower - upper]; +} + +function update(element: HTMLElement, info: TOCThumb): void { + element.style.setProperty('--fd-top', `${info[0]}px`); + element.style.setProperty('--fd-height', `${info[1]}px`); +} + +export function TocThumb({ + containerRef, + ...props +}: HTMLAttributes & { + containerRef: RefObject; +}) { + const active = Primitive.useActiveAnchors(); + const thumbRef = useRef(null); + + const onResize = useEffectEvent(() => { + if (!containerRef.current || !thumbRef.current) return; + + update(thumbRef.current, calc(containerRef.current, active)); + }); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + + onResize(); + const observer = new ResizeObserver(onResize); + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, [containerRef, onResize]); + + useOnChange(active, () => { + if (!containerRef.current || !thumbRef.current) return; + + update(thumbRef.current, calc(containerRef.current, active)); + }); + + return
; +} diff --git a/app/components/ui/toc.tsx b/app/components/ui/toc.tsx new file mode 100644 index 0000000..81cebc2 --- /dev/null +++ b/app/components/ui/toc.tsx @@ -0,0 +1,101 @@ +'use client'; +import type { TOCItemType } from 'fumadocs-core/server'; +import * as Primitive from 'fumadocs-core/toc'; +import { type ComponentProps, createContext, useContext, useRef } from 'react'; +import { cn } from '../../lib/cn'; +import { useI18n } from 'fumadocs-ui/contexts/i18n'; +import { TocThumb } from './toc-thumb'; +import { mergeRefs } from '../../lib/merge-refs'; + +const TOCContext = createContext([]); + +export function useTOCItems(): TOCItemType[] { + return useContext(TOCContext); +} + +export function TOCProvider({ + toc, + children, + ...props +}: ComponentProps) { + return ( + + + {children} + + + ); +} + +export function TOCScrollArea({ + ref, + className, + ...props +}: ComponentProps<'div'>) { + const viewRef = useRef(null); + + return ( +
+ + {props.children} + +
+ ); +} + +export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) { + const containerRef = useRef(null); + const items = useTOCItems(); + const { text } = useI18n(); + + if (items.length === 0) + return ( +
+ {text.tocNoHeadings} +
+ ); + + return ( + <> + +
+ {items.map((item) => ( + + ))} +
+ + ); +} + +function TOCItem({ item }: { item: TOCItemType }) { + return ( + = 4 && 'ps-8', + )} + > + {item.title} + + ); +} diff --git a/app/docs/page.tsx b/app/docs/page.tsx new file mode 100644 index 0000000..8ae640d --- /dev/null +++ b/app/docs/page.tsx @@ -0,0 +1,53 @@ +import type { Route } from "./+types/page"; +import { DocsLayout } from "@/components/layout/docs"; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from "@/components/layout/page"; +import { source } from "@/lib/source"; +import { baseOptions } from "@/lib/layout.shared"; +import { type PageTree } from "fumadocs-core/server"; +import { getMDXComponents } from "../components/mdx-components"; +import { docs } from "../../source.generated"; +import { toClientRenderer } from "fumadocs-mdx/runtime/vite"; + +export async function loader({ params }: Route.LoaderArgs) { + const slugs = params["*"].split("/").filter((v) => v.length > 0); + const page = source.getPage(slugs); + if (!page) throw new Response("Not found", { status: 404 }); + + return { + path: page.path, + tree: source.pageTree, + }; +} + +const renderer = toClientRenderer( + docs.doc, + ({ toc, default: Mdx, frontmatter }) => { + return ( + + {frontmatter.title} + + {frontmatter.title} + {frontmatter.description} + + + + + ); + } +); + +export default function Page(props: Route.ComponentProps) { + const { tree, path } = props.loaderData; + const Content = renderer[path]; + + return ( + + + + ); +} diff --git a/app/docs/search.ts b/app/docs/search.ts new file mode 100644 index 0000000..411f65b --- /dev/null +++ b/app/docs/search.ts @@ -0,0 +1,11 @@ +import { createFromSource } from "fumadocs-core/search/server"; +import { source } from "@/lib/source"; + +const server = createFromSource(source, { + // https://docs.orama.com/docs/orama-js/supported-languages + language: "english", +}); + +export async function loader() { + return server.staticGET(); +} diff --git a/app/lib/cn.ts b/app/lib/cn.ts new file mode 100644 index 0000000..ba66fd2 --- /dev/null +++ b/app/lib/cn.ts @@ -0,0 +1 @@ +export { twMerge as cn } from 'tailwind-merge'; diff --git a/app/lib/is-active.ts b/app/lib/is-active.ts new file mode 100644 index 0000000..d41bf69 --- /dev/null +++ b/app/lib/is-active.ts @@ -0,0 +1,23 @@ +import type { SidebarTab } from 'fumadocs-ui/utils/get-sidebar-tabs'; + +function normalize(url: string) { + if (url.length > 1 && url.endsWith('/')) return url.slice(0, -1); + return url; +} + +export function isActive( + url: string, + pathname: string, + nested = true, +): boolean { + url = normalize(url); + pathname = normalize(pathname); + + return url === pathname || (nested && pathname.startsWith(`${url}/`)); +} + +export function isTabActive(tab: SidebarTab, pathname: string) { + if (tab.urls) return tab.urls.has(normalize(pathname)); + + return isActive(tab.url, pathname, true); +} diff --git a/app/lib/layout.shared.tsx b/app/lib/layout.shared.tsx new file mode 100644 index 0000000..8f9a840 --- /dev/null +++ b/app/lib/layout.shared.tsx @@ -0,0 +1,9 @@ +import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; + +export function baseOptions(): BaseLayoutProps { + return { + nav: { + title: "Tinyauth", + }, + }; +} diff --git a/app/lib/merge-refs.ts b/app/lib/merge-refs.ts new file mode 100644 index 0000000..7d05f74 --- /dev/null +++ b/app/lib/merge-refs.ts @@ -0,0 +1,15 @@ +import type * as React from 'react'; + +export function mergeRefs( + ...refs: (React.Ref | undefined)[] +): React.RefCallback { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + ref.current = value; + } + }); + }; +} diff --git a/app/lib/source.ts b/app/lib/source.ts new file mode 100644 index 0000000..c13211c --- /dev/null +++ b/app/lib/source.ts @@ -0,0 +1,7 @@ +import { loader } from 'fumadocs-core/source'; +import { create, docs } from '../../source.generated'; + +export const source = loader({ + source: await create.sourceAsync(docs.doc, docs.meta), + baseUrl: '/docs', +}); diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..999da05 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,93 @@ +import { + isRouteErrorResponse, + Link, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; +import { RootProvider } from "fumadocs-ui/provider/base"; +import { ReactRouterProvider } from "fumadocs-core/framework/react-router"; +import type { Route } from "./+types/root"; +import "./app.css"; +import SearchDialog from "@/components/search"; + +export const links: Route.LinksFunction = () => [ + { rel: "preconnect", href: "https://fonts.googleapis.com" }, + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", + }, + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + +