shadimage
uiable
blockblock-navbarsource: ok · hit

Navbar

Navbar variant for block.

npx shadcn add "https://uiable.com/r/block-navbar.json"
free

Where it goes

navbar

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

@uiable/avatar@uiable/button@uiable/command@uiable/dropdown-menu@uiable/input@uiable/logo@uiable/separator@uiable/sheet@uiable/slider

Files17 · source cached 10/15/2026, 7:52:47 AM

src/components/uiable/blocks/landing/navbar/navbar.tsx
"use client"

import { ReactNode, useEffect, useRef, useState } from "react"

// next
import Link from "next/link"
import { usePathname } from "next/navigation"

// shadcn
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"

// third-party
import { cn } from "cn"

// project-imports
import branding from "@/branding.json"
import AuthNav from "@/components/auth/auth-nav"
import { ThemeToggle } from "@/components/customizer"
import NavSearchDialog from "@/components/uiable/blocks/landing/components/NavSearchDialog"
import Logo from "@/components/uiable/layout/shared/logo"
import { useGithubStarCount } from "@/hooks/use-github-star-count"
import Star from "@/images/svg/icons/star"

// assets
import {
  IconBrandDiscord,
  IconBrandGithub,
  IconBrandX,
  type Icon as TablerIcon,
} from "@tabler/icons-react"
import { ChevronDown, Menu, Search, X } from "lucide-react"

// types
interface NavLink {
  label: string
  href: string
}

interface ResourceLink {
  label: string
  href: string
  external?: boolean
}

interface SocialLink {
  label: string
  href: string
  icon: TablerIcon
}

const navLinks: NavLink[] = [
  { label: "Components", href: "/components" },
  { label: "Blocks", href: "/blocks" },
  { label: "Templates", href: "/templates" },
  { label: "Dashboards", href: "/admin-dashboard" },
  { label: "Pricing", href: "/pricing" },
]

const supportUrl =
  process.env.NODE_ENV === "development"
    ? "/account/support"
    : branding.company.socialLink.support

const resourceLinks: ResourceLink[] = [
  { label: "Get Started", href: "/doc/installation" },
  { label: "Changelog", href: "/doc/changelog" },
  { label: "Support", href: supportUrl },
]

interface ResourcesMenuProps {
  className?: string
  onLinkClick?: () => void
}

function ResourcesMenu({ className, onLinkClick }: ResourcesMenuProps) {
  const pathname = usePathname()
  const isResourcesActive = resourceLinks.some(
    (item) =>
      !item.external &&
      Boolean(pathname) &&
      (pathname === item.href || pathname.startsWith(`${item.href}/`))
  )

  return (
    <div className={cn("group relative", className)}>
      <Button
        variant="ghost"
        className={cn(
          "gap-1 py-1.5 text-sm leading-6 font-medium group-hover:text-primary hover:bg-transparent hover:text-primary dark:hover:bg-transparent",
          isResourcesActive ? "text-primary" : "text-foreground"
        )}
      >
        Resources
        <ChevronDown
          aria-hidden="true"
          className="size-4 transition-transform duration-200 group-hover:rotate-180"
        />
      </Button>
      <div className="invisible absolute top-full left-0 z-50 min-w-40 pt-2 opacity-0 group-hover:visible group-hover:opacity-100">
        <div className="flex flex-col rounded-md border border-border bg-background py-1">
          {resourceLinks.map((item) => {
            const isActive =
              !item.external &&
              Boolean(pathname) &&
              (pathname === item.href || pathname.startsWith(`${item.href}/`))

            return (
              <Link
                key={item.label}
                href={item.href}
                onClick={onLinkClick}
                target={item.external ? "_blank" : undefined}
                rel={item.external ? "noopener noreferrer" : undefined}
                className={cn(
                  "px-3 py-2 text-sm hover:text-primary",
                  isActive ? "font-medium text-primary" : "text-foreground"
                )}
              >
                {item.label}
              </Link>
            )
          })}
        </div>
      </div>
    </div>
  )
}

function Divider({ className }: { className?: string }) {
  return (
    <Separator orientation="vertical" className={cn("my-1.5", className)} />
  )
}

interface NavLinksProps {
  className?: string
  onLinkClick?: () => void
}

function NavLinks({ className, onLinkClick }: NavLinksProps) {
  const pathname = usePathname()

  return (
    <>
      {navLinks.map((item) => {
        const isActive =
          Boolean(pathname) &&
          (pathname === item.href ||
            (item.href !== "/" && pathname.startsWith(`${item.href}/`)))

        return (
          <Button
            key={item.label}
            variant="ghost"
            nativeButton={false}
            onClick={onLinkClick}
            render={<Link href={item.href} />}
            aria-current={isActive ? "page" : undefined}
            className={cn(
              "py-1.5 text-sm leading-6 font-medium hover:bg-transparent hover:text-primary dark:hover:bg-transparent",
              isActive ? "text-primary" : "text-foreground",
              className
            )}
          >
            {item.label}
          </Button>
        )
      })}
    </>
  )
}

const socialLinks: SocialLink[] = [
  { label: "Twitter", href: branding.company.socialLink.x, icon: IconBrandX },
  {
    label: "Discord",
    href: branding.company.socialLink.discord,
    icon: IconBrandDiscord,
  },
]

interface SocialActionsProps {
  className?: string
  githubStars: string
  showThemeToggle?: boolean
  onSearchClick?: () => void
  onAuthAction?: () => void
}

function SocialActions({
  className = "",
  githubStars,
  showThemeToggle = true,
  onSearchClick,
  onAuthAction,
}: SocialActionsProps) {
  return (
    <div className={className}>
      <Button
        variant="ghost"
        size="icon-lg"
        aria-label="Search"
        onClick={onSearchClick}
        className="flex h-10.5 w-10.5 cursor-pointer items-center justify-center rounded-sm hover:bg-foreground/10 dark:hover:bg-muted"
      >
        <Search aria-hidden="true" className="size-4.5" />
      </Button>
      <Divider />

      {socialLinks.map((social) => (
        <Button
          key={social.label}
          variant="ghost"
          size="icon-lg"
          aria-label={social.label}
          nativeButton={false}
          render={
            <Link
              href={social.href}
              target="_blank"
              rel="noopener noreferrer"
            />
          }
          className="flex h-10.5 w-10.5 items-center justify-center rounded-sm hover:bg-foreground/10 dark:hover:bg-muted"
        >
          <social.icon aria-hidden="true" className="size-4.5" />
        </Button>
      ))}

      <Button
        variant="ghost"
        aria-label="Github"
        nativeButton={false}
        render={
          <Link
            href={branding.company.socialLink.github}
            target="_blank"
            rel="noopener noreferrer"
          />
        }
        className="flex h-9 items-center gap-2 rounded-full bg-sidebar-border px-3.5 text-foreground hover:bg-sidebar-border/90 hover:text-foreground"
      >
        <IconBrandGithub aria-hidden="true" className="size-4" />
        <span className="text-sm font-semibold">{githubStars}</span>
      </Button>

      {showThemeToggle && (
        <>
          <Divider />
          <ThemeToggle />
        </>
      )}
      <Divider className="hidden md:block" />
      <div className="flex w-full pt-2 md:w-auto md:pt-0">
        <AuthNav onNavigate={onAuthAction} />
      </div>
    </div>
  )
}

function StickyWrapper({ children }: { children: ReactNode }) {
  return (
    <div className="sticky top-0 z-50 w-full bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/60">
      <div className="relative container mx-auto border-x border-border/60 max-[1199px]:max-w-none!">
        {children}
        <div className="absolute bottom-0 left-0 z-50 -translate-x-2/4 translate-y-2/4 text-border/60">
          <Star />
        </div>
        <div className="absolute right-0 bottom-0 z-50 translate-x-2/4 translate-y-2/4 text-border/60">
          <Star />
        </div>
      </div>
      <div className="absolute bottom-0 left-0 h-px w-full bg-border/60" />
    </div>
  )
}

//  ------------------------------ | LAYOUT - NAVBAR | ------------------------------  //

export default function Navbar() {
  const pathname = usePathname()
  const [prevPathname, setPrevPathname] = useState(pathname)
  const [searchOpen, setSearchOpen] = useState(false)
  const [menuOpen, setMenuOpen] = useState(false)
  const menuRef = useRef<HTMLDivElement>(null)
  const toggleRef = useRef<HTMLButtonElement>(null)
  const githubStars = useGithubStarCount()

  if (pathname !== prevPathname) {
    setPrevPathname(pathname)
    setMenuOpen(false)
  }

  useEffect(() => {
    if (!menuOpen) return

    function handlePointerDown(event: MouseEvent) {
      const target = event.target as Node
      if (
        menuRef.current?.contains(target) ||
        toggleRef.current?.contains(target)
      ) {
        return
      }
      // The account menu (My Account / Logout) and the search dialog render in
      // a portal on <body>, i.e. outside menuRef. Without this guard, tapping
      // one of those items counts as an "outside" click and tears down the
      // mobile menu — along with the item being tapped — before its handler
      // runs, so the links appear dead on mobile.
      if (
        target instanceof Element &&
        target.closest(
          '[data-slot="dropdown-menu-content"],[role="menu"],[role="dialog"]'
        )
      ) {
        return
      }
      setMenuOpen(false)
    }

    document.addEventListener("mousedown", handlePointerDown)
    return () => document.removeEventListener("mousedown", handlePointerDown)
  }, [menuOpen])

  return (
    <StickyWrapper>
      <header className="w-full">
        <div className="z-50 flex h-19.5 w-full items-center justify-between px-4 sm:px-8">
          <div className="flex items-center gap-8 lg:gap-4 xl:gap-8">
            <Logo />

            <nav className="hidden items-center gap-0.5 lg:flex xl:gap-0.5">
              <NavLinks className="px-3" />
              <ResourcesMenu />
            </nav>
          </div>

          <SocialActions
            className="hidden items-center gap-2 lg:flex"
            githubStars={githubStars}
            onSearchClick={() => setSearchOpen(true)}
          />

          <div className="flex items-center gap-1 lg:hidden">
            <SocialActions
              className="hidden items-center gap-2 md:flex"
              githubStars={githubStars}
              onSearchClick={() => setSearchOpen(true)}
            />
            <Button
              ref={toggleRef}
              variant="ghost"
              aria-label={menuOpen ? "Close menu" : "Open menu"}
              onClick={() => setMenuOpen(!menuOpen)}
              className="flex h-10 w-10 items-center justify-center rounded-sm"
            >
              {menuOpen ? (
                <X aria-hidden="true" className="size-4.5" />
              ) : (
                <Menu aria-hidden="true" className="size-4.5" />
              )}
            </Button>

            <div className="md:hidden">
              <ThemeToggle />
            </div>
          </div>
        </div>

        {menuOpen && (
          <div
            ref={menuRef}
            className="absolute top-full left-0 w-full border-b border-border/60 bg-background shadow-lg lg:hidden"
          >
            <div className="flex flex-col gap-4 p-4">
              <nav className="flex flex-col gap-3">
                <NavLinks
                  className="justify-start px-0 text-base"
                  onLinkClick={() => setMenuOpen(false)}
                />
                <div className="flex flex-col gap-1 border-t border-border/60 pt-3">
                  <span className="px-0 pb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
                    Resources
                  </span>
                  {resourceLinks.map((item) => {
                    const isActive =
                      !item.external &&
                      Boolean(pathname) &&
                      (pathname === item.href ||
                        pathname.startsWith(`${item.href}/`))

                    return (
                      <Link
                        key={item.label}
                        href={item.href}
                        onClick={() => setMenuOpen(false)}
                        target={item.external ? "_blank" : undefined}
                        rel={item.external ? "noopener noreferrer" : undefined}
                        className={cn(
                          "py-1.5 text-base font-medium hover:text-primary",
                          isActive ? "text-primary" : "text-foreground"
                        )}
                      >
                        {item.label}
                      </Link>
                    )
                  })}
                </div>
              </nav>
              <div className="mt-auto flex flex-col border-t border-border/60 pt-4 pb-4 md:hidden">
                <SocialActions
                  className="flex flex-wrap items-center justify-center gap-2"
                  githubStars={githubStars}
                  showThemeToggle={false}
                  onSearchClick={() => {
                    setSearchOpen(true)
                    setMenuOpen(false)
                  }}
                  onAuthAction={() => setMenuOpen(false)}
                />
              </div>
            </div>
          </div>
        )}
        <NavSearchDialog open={searchOpen} setOpen={setSearchOpen} />
      </header>
    </StickyWrapper>
  )
}
src/branding.json
{
  "brandName": "UIAble",
  "title": "Components to Complete Interfaces",
  "company": {
    "name": "CodedThemes ",
    "url": "https://codedthemes.com/",
    "socialLink": {
      "linkedin": "https://in.linkedin.com/company/codedthemes",
      "x": "https://x.com/uiable",
      "instagram": "#",
      "github": "https://github.com/codedthemes/uiable",
      "discord": "https://discord.com/invite/txPuEn5atw",
      "support": "https://uiable.com/account/support"
    }
  },
  "logo": {
    "main": "",
    "logoIcon": "",
    "logoFab": "",
    "logoWatermark": ""
  }
}
src/category-counts.json
{
  "accordion": 10,
  "alert": 46,
  "alert-dialog": 10,
  "aspect-ratio": 5,
  "attachment": 5,
  "avatar": 15,
  "badge": 37,
  "layout": 14,
  "breadcrumb": 11,
  "bubble": 8,
  "button": 49,
  "button-group": 15,
  "calendar": 12,
  "card": 24,
  "carousel": 9,
  "changelog": 3,
  "chart": 10,
  "checkbox": 11,
  "collapsible": 6,
  "combobox": 14,
  "command": 9,
  "context-menu": 13,
  "coupon": 3,
  "cursor": 3,
  "data-table": 4,
  "date-picker": 8,
  "dialog": 15,
  "drawer": 6,
  "dropdown-menu": 25,
  "empty": 8,
  "field": 12,
  "hover-card": 5,
  "input": 16,
  "input-group": 22,
  "input-otp": 11,
  "item": 15,
  "kbd": 7,
  "label": 4,
  "list-group": 13,
  "marker": 7,
  "menubar": 8,
  "message": 5,
  "message-scroller": 9,
  "native-select": 3,
  "navigation-menu": 3,
  "pagination": 11,
  "popover": 8,
  "progress": 20,
  "questionnaire": 7,
  "radio": 1,
  "radio-group": 9,
  "resizable": 7,
  "scroll-area": 5,
  "select": 10,
  "separator": 8,
  "sheet": 5,
  "sidebar": 17,
  "skeleton": 8,
  "slider": 8,
  "sonner": 8,
  "spinner": 21,
  "switch": 11,
  "table": 6,
  "tabs": 10,
  "textarea": 8,
  "timeline": 7,
  "toggle": 6,
  "toggle-group": 8,
  "tooltip": 11,
  "tree-view": 7,
  "typography": 22,
  "contact": 24,
  "content": 17,
  "cta": 12,
  "e-commerce": 25,
  "faq": 19,
  "feature": 42,
  "footer": 21,
  "gallery": 11,
  "hero": 32,
  "landing": 14,
  "portfolio": 11,
  "pricing": 19,
  "process": 12,
  "small-hero": 7,
  "statistics": 13,
  "team": 14,
  "testimonial": 24,
  "bento": 8,
  "charts": 8,
  "check-mail": 2,
  "code-verification": 7,
  "coming-soon": 2,
  "component-layout": 1,
  "dashboard-layout": 3,
  "doc-layout": 1,
  "error-404": 2,
  "error-500": 2,
  "forgot-password": 5,
  "join-waitlist": 1,
  "login": 7,
  "navbar": 11,
  "reset-password": 1,
  "sign-up": 7,
  "tables": 1,
  "under-construction": 2,
  "widgets": 20
}
src/components-grid.ts
// project-imports
import CATEGORY_COUNTS from "@/category-counts.json"

export interface CategoryItem {
  title: string
  slug: string
  badge?: {
    label: string
  }
  breakpoints?: {
    xs?: number
    sm?: number
    md?: number
    lg?: number
    xl?: number
    xxl?: number
  }
}

export interface NavSection {
  title: string
  items: CategoryItem[]
}

export interface DocItem {
  title: string
  slug: string
}

export const NAV_DOCS: DocItem[] = [
  { title: "Introduction", slug: "introduction" },
  { title: "Pro Purchase Guide", slug: "pro-purchase-guide" },
  { title: "Installation", slug: "installation" },
  { title: "Shadcn CLI", slug: "cli" },
  { title: "Components", slug: "components" },
  { title: "Blocks", slug: "blocks" },
  { title: "Templates", slug: "templates" },
  { title: "Admin Dashboard", slug: "admin-dashboard" },
  { title: "Figma", slug: "figma" },
  { title: "MCP", slug: "mcp" },
  { title: "Changelog", slug: "changelog" },
  { title: "License", slug: "license" },
]

export const NAV_COMPONENTS: NavSection[] = [
  {
    title: "Inputs",
    items: [
      {
        title: "Button",
        slug: "button",
        badge: { label: "New" },
        breakpoints: { xl: 3, lg: 2, xs: 1 },
      },
      {
        title: "Button Group",
        slug: "button-group",
        breakpoints: { xxl: 3, lg: 2, sm: 1, xs: 1 },
      },
      {
        title: "Checkbox",
        slug: "checkbox",
        breakpoints: { lg: 2, xs: 1 },
      },
      {
        title: "Combobox",
        slug: "combobox",
        breakpoints: { xl: 3, lg: 2, xs: 1 },
      },
      {
        title: "Command",
        slug: "command",
        breakpoints: { xl: 3, lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Date Picker",
        slug: "date-picker",
        breakpoints: { xl: 3, lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Input",
        slug: "input",
        breakpoints: { lg: 2, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Input Group",
        slug: "input-group",
        breakpoints: { md: 2, sm: 1, xs: 1 },
      },
      { title: "Input OTP", slug: "input-otp", breakpoints: { md: 2, xs: 1 } },
      { title: "Item", slug: "item", breakpoints: { md: 2, xs: 1 } },
      {
        title: "Native Select",
        slug: "native-select",
        breakpoints: { lg: 3, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Questionnaire",
        slug: "questionnaire",
        badge: { label: "New" },
        breakpoints: { xl: 2, lg: 2, md: 1, sm: 1, xs: 1 },
      },
      { title: "Radio", slug: "radio", breakpoints: { xs: 1 } },
      {
        title: "Radio Group",
        slug: "radio-group",
        breakpoints: { lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Select",
        slug: "select",
        badge: { label: "New" },
        breakpoints: { lg: 3, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Slider",
        slug: "slider",
        badge: { label: "New" },
        breakpoints: { xl: 3, lg: 2, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Switch",
        slug: "switch",
        badge: { label: "New" },
        breakpoints: { md: 2, sm: 2, xs: 1 },
      },
      {
        title: "Textarea",
        slug: "textarea",
        breakpoints: { lg: 2, md: 2, sm: 1, xs: 1 },
      },
      { title: "Toggle", slug: "toggle", breakpoints: { sm: 2, xs: 1 } },
      {
        title: "Toggle Group",
        slug: "toggle-group",
        badge: { label: "New" },
        breakpoints: { md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Calendar",
        slug: "calendar",
        badge: { label: "New" },
        breakpoints: { lg: 2, sm: 1, xs: 1 },
      },
    ],
  },
  {
    title: "Data Display",
    items: [
      {
        title: "Coupon",
        slug: "coupon",
        badge: { label: "New" },
        breakpoints: { lg: 2, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Attachment",
        slug: "attachment",
        breakpoints: { lg: 2, xs: 1 },
      },
      {
        title: "Avatar",
        slug: "avatar",
        badge: { label: "New" },
        breakpoints: { lg: 3, sm: 2, xs: 1 },
      },
      {
        title: "Badge",
        slug: "badge",
        badge: { label: "New" },
        breakpoints: { lg: 3, sm: 2, xs: 1 },
      },
      {
        title: "Bubble",
        slug: "bubble",
        breakpoints: { lg: 2, xs: 1 },
      },
      {
        title: "Card",
        slug: "card",
        badge: { label: "New" },
        breakpoints: { lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Carousel",
        slug: "carousel",
        badge: { label: "New" },
        breakpoints: { xl: 2, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Chart",
        slug: "chart",
        breakpoints: { xl: 2, lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Data Table",
        slug: "data-table",
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Empty",
        slug: "empty",
        breakpoints: { xl: 3, lg: 2, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Field",
        slug: "field",
        breakpoints: { lg: 2, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Hover Card",
        slug: "hover-card",
        breakpoints: { md: 2, sm: 1, xs: 1 },
      },
      { title: "Kbd", slug: "kbd", breakpoints: { md: 2, sm: 2, xs: 1 } },
      { title: "Label", slug: "label", breakpoints: { xs: 1 } },
      {
        title: "Marker",
        slug: "marker",
        breakpoints: { lg: 2, xs: 1 },
      },
      {
        title: "Message",
        slug: "message",
        breakpoints: { lg: 2, xs: 1 },
      },
      {
        title: "Message Scroller",
        slug: "message-scroller",
        breakpoints: { lg: 2, xs: 1 },
      },
      {
        title: "List Group",
        slug: "list-group",
        breakpoints: { lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Scroll Area",
        slug: "scroll-area",
        breakpoints: { lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Separator",
        slug: "separator",
        breakpoints: { md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Table",
        slug: "table",
        breakpoints: { xl: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Tooltip",
        slug: "tooltip",
        breakpoints: { lg: 3, sm: 2, xs: 1 },
      },
      {
        title: "Typography",
        slug: "typography",
        breakpoints: { xl: 2, lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Aspect Ratio",
        slug: "aspect-ratio",
        breakpoints: { md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Skeleton",
        slug: "skeleton",
        breakpoints: { lg: 3, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Timeline",
        slug: "timeline",
        badge: { label: "New" },
        breakpoints: { xl: 2, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Tree View",
        slug: "tree-view",
        badge: { label: "New" },
        breakpoints: { xl: 2, lg: 1, md: 1, sm: 1, xs: 1 },
      },
    ],
  },
  {
    title: "Feedback",
    items: [
      {
        title: "Alert",
        slug: "alert",
        breakpoints: { xl: 2, lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Alert Dialog",
        slug: "alert-dialog",
        breakpoints: { xl: 3, lg: 3, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Dialog",
        slug: "dialog",
        breakpoints: { xl: 3, lg: 3, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Drawer",
        slug: "drawer",
        breakpoints: { xl: 3, lg: 2, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Progress",
        slug: "progress",
        breakpoints: { xl: 3, lg: 2, md: 2, sm: 1, xs: 1 },
      },
      { title: "Sheet", slug: "sheet", breakpoints: { md: 2, xs: 1 } },
      { title: "Sonner", slug: "sonner", breakpoints: { md: 2, sm: 1, xs: 1 } },
      {
        title: "Spinner",
        slug: "spinner",
        breakpoints: { lg: 3, sm: 2, xs: 1 },
      },
    ],
  },
  {
    title: "Navigation",
    items: [
      {
        title: "Breadcrumb",
        slug: "breadcrumb",
        breakpoints: { xl: 2, lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Dropdown Menu",
        slug: "dropdown-menu",
        badge: { label: "New" },
        breakpoints: { lg: 3, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Menubar",
        slug: "menubar",
        badge: { label: "New" },
        breakpoints: { lg: 2, md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Navigation Menu",
        slug: "navigation-menu",
        badge: { label: "New" },
        breakpoints: { xs: 1 },
      },
      {
        title: "Pagination",
        slug: "pagination",
        badge: { label: "New" },
        breakpoints: { xl: 2, lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Tabs",
        slug: "tabs",
        badge: { label: "New" },
        breakpoints: { lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Sidebar",
        slug: "sidebar",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
    ],
  },
  {
    title: "Surfaces",
    items: [
      {
        title: "Accordion",
        slug: "accordion",
        badge: { label: "New" },
        breakpoints: { xl: 2, lg: 2, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Collapsible",
        slug: "collapsible",
        badge: { label: "New" },
        breakpoints: { lg: 2, md: 1, sm: 1, xs: 1 },
      },
    ],
  },
  {
    title: "Utils",
    items: [
      {
        title: "Context Menu",
        slug: "context-menu",
        breakpoints: { md: 2, sm: 1, xs: 1 },
      },
      {
        title: "Cursor",
        slug: "cursor",
        breakpoints: { xl: 2, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Popover",
        slug: "popover",
        badge: { label: "New" },
        breakpoints: { lg: 3, md: 2, sm: 1 },
      },
      {
        title: "Resizable",
        slug: "resizable",
        badge: { label: "New" },
        breakpoints: { xl: 2, lg: 1, md: 1, sm: 1, xs: 1 },
      },
    ],
  },
]

export const NAV_BLOCKS: NavSection[] = [
  {
    title: "UI",
    items: [
      {
        title: "Bento",
        slug: "bento",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Call To Action",
        slug: "cta",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Contact",
        slug: "contact",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Content",
        slug: "content",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "E-commerce",
        slug: "e-commerce",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "FAQ",
        slug: "faq",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Feature",
        slug: "feature",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Footer",
        slug: "footer",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Gallery",
        slug: "gallery",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Hero",
        slug: "hero",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Small Hero",
        slug: "small-hero",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Portfolio",
        slug: "portfolio",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Pricing",
        slug: "pricing",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Process",
        slug: "process",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Statistics",
        slug: "statistics",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Team",
        slug: "team",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Testimonial",
        slug: "testimonial",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Login",
        slug: "login",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Sign Up",
        slug: "sign-up",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Forgot Password",
        slug: "forgot-password",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Reset Password",
        slug: "reset-password",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Check Mail",
        slug: "check-mail",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Code Verification",
        slug: "code-verification",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Error 404",
        slug: "error-404",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Error 500",
        slug: "error-500",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Coming Soon",
        slug: "coming-soon",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Under Construction",
        slug: "under-construction",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Join Waitlist",
        slug: "join-waitlist",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Widgets",
        slug: "widgets",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Charts",
        slug: "charts",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Tables",
        slug: "tables",
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Navbar",
        slug: "navbar",
        badge: { label: "New" },
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
    ],
  },
  {
    title: "Layout",
    items: [
      {
        title: "Dashboard Layout",
        slug: "dashboard-layout",
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Component Layout",
        slug: "component-layout",
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
      {
        title: "Doc Layout",
        slug: "doc-layout",
        breakpoints: { xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
      },
    ],
  },
]

export const NAV_TEMPLATES: NavSection[] = [
  {
    title: "LMS",
    items: [{ title: "Learnly", slug: "lms-1" }],
  },
  {
    title: "Finance",
    items: [{ title: "Housiq", slug: "finance-1" }],
  },
  {
    title: "AI",
    items: [{ title: "Cortex AI", slug: "ai-1" }],
  },
  {
    title: "Portfolio",
    items: [{ title: "Portfolio", slug: "portfolio-1" }],
  },
  {
    title: "SaaS",
    items: [{ title: "Nexus", slug: "saas-1" }],
  },
  {
    title: "SEO",
    items: [{ title: "wope", slug: "seo-1" }],
  },
]

export const NAV_DASHBOARD: NavSection[] = [
  {
    title: "DASHBOARD",
    items: [
      { title: "OrbitCN Default Dashboard", slug: "default-dashboard" },
      { title: "OrbitCN LMS Dashboard", slug: "lms-dashboard" },
      { title: "OrbitCN Finance Dashboard", slug: "finance-dashboard" },
    ],
  },
]

export const categories = [
  ...NAV_COMPONENTS,
  ...NAV_BLOCKS,
  ...NAV_TEMPLATES,
  ...NAV_DASHBOARD,
].flatMap((section) => section.items)

export function totalFor(sections: NavSection[]) {
  return sections
    .flatMap((section) => section.items)
    .reduce(
      (total, item) =>
        total +
        (CATEGORY_COUNTS[item.slug as keyof typeof CATEGORY_COUNTS] || 0),
      0
    )
}

export const TOTAL_COMPONENTS = totalFor(NAV_COMPONENTS)
export const TOTAL_BLOCKS = totalFor(NAV_BLOCKS)
src/components/auth/auth-nav.tsx
"use client"

import { useEffect, useState } from "react"

// next
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"

// shadcn
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

// third-party
import type { User } from "@supabase/supabase-js"

// project-imports
import { rememberLastAuthMethod } from "@/lib/auth/last-auth-method"
import { createClient } from "@/lib/supabase/client"
import { isSupabaseConfigured } from "@/lib/supabase/config"

// assets
import { IconLogout, IconUserCircle } from "@tabler/icons-react"

//  ------------------------------ | AUTH - NAV | ------------------------------  //

function initialsOf(user: User): string {
  const name =
    (user.user_metadata?.full_name as string | undefined) || user.email || "?"
  return name
    .split(" ")
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]!.toUpperCase())
    .join("")
}

const proCacheKey = (userId: string) => `uiable:has-pro:${userId}`

function readCachedPro(userId: string): boolean | null {
  try {
    const value = localStorage.getItem(proCacheKey(userId))
    return value === null ? null : value === "1"
  } catch {
    return null
  }
}

function writeCachedPro(userId: string, hasPro: boolean) {
  try {
    localStorage.setItem(proCacheKey(userId), hasPro ? "1" : "0")
  } catch {}
}

// `onNavigate` lets a container (e.g. the mobile nav panel) close itself when
// an account action is taken.
interface AuthNavProps {
  onNavigate?: () => void
}

export default function AuthNav({ onNavigate }: AuthNavProps = {}) {
  const router = useRouter()
  const pathname = usePathname()

  const [user, setUser] = useState<User | null>(null)
  const [hasPro, setHasPro] = useState<boolean | null>(false)
  const [loading, setLoading] = useState(isSupabaseConfigured())

  useEffect(() => {
    if (!isSupabaseConfigured()) return

    const supabase = createClient()

    // Mirrors hasProAccess() (src/lib/entitlements.ts) — same table/columns,
    // run client-side under RLS as the signed-in user.
    async function checkEntitlement(userId: string) {
      const { data } = await supabase
        .from("entitlements")
        .select("status")
        .eq("user_id", userId)
        .eq("status", "active")
        .maybeSingle()
      setHasPro(Boolean(data))
      writeCachedPro(userId, Boolean(data))
    }

    async function verifyUser() {
      const { data } = await supabase.auth.getUser()
      if (!data.user) {
        setUser(null)
        setHasPro(false)
      }
    }

    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((event, session) => {
      const nextUser = session?.user ?? null
      setUser(nextUser)
      setLoading(false)

      if (!nextUser) {
        setHasPro(false)
        return
      }

      rememberLastAuthMethod(nextUser)
      setHasPro(readCachedPro(nextUser.id))
      setTimeout(async () => {
        await checkEntitlement(nextUser.id)
        if (event === "INITIAL_SESSION") await verifyUser()
      }, 0)
    })
    return () => subscription.unsubscribe()
  }, [])

  async function handleLogout() {
    onNavigate?.()
    const supabase = createClient()
    await supabase.auth.signOut()
    router.push("/")
    router.refresh()
  }

  function goToAccount() {
    onNavigate?.()
    router.push("/account")
  }

  // Reserve the slot while the session loads to avoid layout shift.
  if (loading) {
    return <span aria-hidden="true" className="inline-block h-9 w-60" />
  }

  if (!user) {
    return (
      <div className="flex w-full items-center justify-between gap-2 md:w-auto md:justify-start">
        <Button
          size="sm"
          nativeButton={false}
          render={
            <Link
              href={`/auth/login?callbackUrl=${encodeURIComponent(pathname)}`}
            />
          }
          className="h-9 flex-1 rounded-lg border-mist-800 bg-transparent px-4 font-medium text-mist-800 hover:bg-mist-800 hover:text-white md:flex-none dark:border-mist-400 dark:text-mist-400"
        >
          Sign in
        </Button>
        <Button
          size="sm"
          nativeButton={false}
          render={<Link href="/pricing" />}
          className="h-9 flex-1 rounded-lg bg-mist-800 px-4 font-medium text-white hover:bg-mist-900 md:flex-none"
        >
          Get all access
        </Button>
      </div>
    )
  }

  const avatarUrl = user.user_metadata?.avatar_url as string | undefined
  const displayName =
    (user.user_metadata?.full_name as string | undefined) || user.email

  return (
    <div className="flex w-full items-center justify-center gap-4 md:w-auto md:justify-start">
      <DropdownMenu>
        <DropdownMenuTrigger
          aria-label="Account menu"
          className="rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
        >
          <div className="flex w-full items-start gap-2">
            <Avatar className="size-9 cursor-pointer border border-border/60">
              {avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
              <AvatarFallback className="bg-primary/10 text-xs font-semibold text-primary">
                {initialsOf(user)}
              </AvatarFallback>
            </Avatar>
            <div className="flex flex-col items-start gap-0.5 md:hidden">
              <span className="text-sm font-medium text-foreground">
                {displayName}
              </span>
              {displayName !== user.email && (
                <span className="text-xs font-normal text-muted-foreground capitalize">
                  {user.role}
                </span>
              )}
            </div>
          </div>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end" className="min-w-56">
          {/* DropdownMenuLabel is a Base UI GroupLabel — it must be inside a
            DropdownMenuGroup or Base UI throws at render. */}
          <DropdownMenuGroup>
            <DropdownMenuLabel className="flex flex-col gap-0.5">
              <span className="text-sm font-medium text-foreground">
                {displayName}
              </span>
              {displayName !== user.email && (
                <span className="text-xs font-normal text-muted-foreground">
                  {user.email}
                </span>
              )}
            </DropdownMenuLabel>
          </DropdownMenuGroup>
          <DropdownMenuSeparator />
          <DropdownMenuItem
            className="cursor-pointer gap-2.5"
            onClick={goToAccount}
          >
            <IconUserCircle className="size-4.5 text-muted-foreground" />
            <span>My Account</span>
          </DropdownMenuItem>
          <DropdownMenuItem
            className="cursor-pointer gap-2.5"
            onClick={handleLogout}
          >
            <IconLogout className="size-4.5 text-muted-foreground" />
            <span>Logout</span>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
      {hasPro === null ? (
        <span
          aria-hidden="true"
          className="inline-block h-9 max-w-[180px] flex-1 md:w-28 md:flex-none"
        />
      ) : hasPro ? (
        <Button
          size="sm"
          nativeButton={false}
          render={<Link href="/account" />}
          className="h-9 max-w-[180px] flex-1 rounded-lg bg-green-600 px-4 font-medium text-white hover:bg-green-700 md:flex-none dark:bg-green-600 dark:hover:bg-green-700"
        >
          Premium
        </Button>
      ) : (
        <Button
          size="sm"
          nativeButton={false}
          render={<Link href="/pricing" />}
          className="h-9 max-w-[180px] flex-1 rounded-lg bg-mist-800 px-4 font-medium text-white hover:bg-mist-900 md:flex-none"
        >
          Get all access
        </Button>
      )}
    </div>
  )
}
src/components/customizer/RadiusControl.tsx
"use client"

// shadcn
import { Input } from "@/components/ui/input"
import { Slider } from "@/components/ui/slider"

type RadiusControlProps = {
  radius: number
  onRadiusChange: (value: number) => void
}

//  ------------------------------ | CUSTOMIZER - RADIUS CONTROL | ------------------------------  //

export function RadiusControl({ radius, onRadiusChange }: RadiusControlProps) {
  return (
    <>
      <div className="flex flex-col gap-1">
        <h6>Theme Radius</h6>
        <p className="text-foreground">Choose radius for theme</p>
      </div>
      <div className="flex items-center gap-4">
        <div className="grow">
          <Slider
            value={[radius]}
            onValueChange={(val) =>
              onRadiusChange(Array.isArray(val) ? val[0] : val)
            }
            min={0}
            max={1.25}
            step={0.05}
          />
        </div>
        <div className="shrink-0">
          <p className="flex gap-x-2">
            <Input
              type="number"
              value={radius}
              onChange={(e) => {
                const value = parseFloat(e.target.value)
                onRadiusChange(isNaN(value) ? 0 : value)
              }}
              min={0}
              max={1.25}
              step={0.05}
              className="h-8 w-15 px-2 text-xs"
            />
            rem
          </p>
        </div>
      </div>
    </>
  )
}
src/components/customizer/ThemePresetButtons.tsx
"use client"

// shadcn
import { Button } from "@/components/ui/button"

const PRESET_BUTTONS = [
  { label: "Default", value: "default" },
  { label: "Ghibli Studio", value: "ghibli-studio" },
  { label: "Perplexity", value: "perplexity" },
  { label: "Marvel", value: "marvel" },
  { label: "Material", value: "material" },
  { label: "Vs Code", value: "vscode" },
  { label: "Datta", value: "datta" },
  { label: "Flat", value: "flat" },
  { label: "Guru", value: "guru" },
  { label: "Berry", value: "berry" },
]

type ThemePresetButtonsProps = {
  onSelectPreset: (className: string) => void
}

//  ------------------------------ | CUSTOMIZER - THEME PRESET BUTTONS | ------------------------------  //

export function ThemePresetButtons({
  onSelectPreset,
}: ThemePresetButtonsProps) {
  return (
    <div className="grid grid-cols-2 gap-2">
      {PRESET_BUTTONS.map((preset) => (
        <Button
          key={preset.value}
          variant="outline"
          onClick={() => onSelectPreset(preset.value)}
        >
          {preset.label}
        </Button>
      ))}
    </div>
  )
}
src/components/customizer/ThemePresetStyles.tsx
const PRESET_DATA: Record<string, string[]> = {
  "preset-2": [
    "255 226 226",
    "255 162 162",
    "251 44 54",
    "193 0 7",
    "130 24 26",
  ],
  "preset-3": [
    "255 237 212",
    "255 184 106",
    "255 105 0",
    "202 53 0",
    "126 42 12",
  ],
  "preset-4": [
    "254 243 198",
    "255 210 48",
    "254 154 0",
    "187 77 0",
    "123 51 6",
  ],
  "preset-5": [
    "254 249 194",
    "255 223 32",
    "240 177 0",
    "166 95 0",
    "115 62 10",
  ],
  "preset-6": [
    "236 252 202",
    "187 244 81",
    "124 207 0",
    "73 125 0",
    "53 83 14",
  ],
  "preset-7": [
    "220 252 231",
    "123 241 168",
    "0 201 80",
    "0 130 54",
    "13 84 43",
  ],
  "preset-8": ["208 250 229", "94 233 181", "0 188 125", "0 122 85", "0 79 59"],
  "preset-9": [
    "203 251 241",
    "70 236 213",
    "0 187 167",
    "0 120 111",
    "11 79 74",
  ],
  "preset-10": [
    "206 250 254",
    "83 234 253",
    "0 184 219",
    "0 117 149",
    "16 78 100",
  ],
  "preset-11": [
    "223 242 254",
    "116 212 255",
    "0 166 244",
    "0 105 168",
    "2 74 112",
  ],
  "preset-12": [
    "219 234 254",
    "142 197 255",
    "43 127 255",
    "20 71 230",
    "28 57 142",
  ],
  "preset-13": [
    "224 231 255",
    "163 179 255",
    "97 95 255",
    "67 45 215",
    "49 44 133",
  ],
  "preset-14": [
    "237 233 254",
    "196 180 255",
    "142 81 255",
    "112 8 231",
    "77 23 154",
  ],
  "preset-15": [
    "243 232 255",
    "218 178 255",
    "173 70 255",
    "130 0 219",
    "89 22 139",
  ],
  "preset-16": [
    "250 232 255",
    "244 168 255",
    "225 42 251",
    "168 0 183",
    "114 19 120",
  ],
  "preset-17": [
    "252 231 243",
    "253 165 213",
    "246 51 154",
    "198 0 92",
    "134 16 67",
  ],
  "preset-18": [
    "255 228 230",
    "255 161 173",
    "255 32 86",
    "199 0 54",
    "139 8 54",
  ],
}

const toOpaque = (rgb: string, alpha: number, base: number = 255) => {
  const [r, g, b] = rgb.split(" ").map(Number)
  const blend = (c: number) => Math.round(c * alpha + base * (1 - alpha))
  return `rgb(${blend(r)} ${blend(g)} ${blend(b)})`
}

const buildPresetStyles = () =>
  Object.entries(PRESET_DATA)
    .map(
      ([name, colors]) => `
.${name} {
  --background: ${toOpaque(colors[0], 0.2, 255)};
  --foreground: #131920;
  --card: #fff;
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: rgb(${colors[2]});
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: ${toOpaque(colors[0], 0.1, 255)};
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.58 0.22 27);
  --border: #e7eaee;
  --input: #fff;
  --ring: oklch(0.708 0 0);
  --chart-1: rgb(${colors[0]});
  --chart-2: rgb(${colors[1]});
  --chart-3: rgb(${colors[2]});
  --chart-4: rgb(${colors[3]});
  --chart-5: rgb(${colors[4]});
  --sidebar: ${toOpaque(colors[0], 0.2, 255)};
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-primary: oklch(0.88 0.0557 266.22);
  --sidebar-primary-foreground: var(--primary);
  --sidebar-accent: ${toOpaque(colors[0], 0.6, 255)};
  --sidebar-accent-foreground: var(--primary);
  --sidebar-border: oklch(0.8275 0.0157 241.81);
  --sidebar-ring: oklch(0.708 0 0);
}

.dark .${name} {
  --background:${toOpaque(colors[4], 0.04, 10)};
  --foreground: #bfbfbf;
  --card: ${toOpaque(colors[4], 0.15, 25)};
  --card-foreground: oklch(0.985 0 0);
  --popover: ${toOpaque(colors[4], 0.15, 15)};
  --popover-foreground: oklch(0.985 0 0);
  --primary: rgb(${colors[2]});
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: ${toOpaque(colors[4], 0.03, 15)};
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --border: ${toOpaque(colors[1], 0.25, 0)};
  --input: ${toOpaque(colors[4], 0.15, 15)};
  --ring: oklch(0.556 0 0);
  --chart-1: rgb(${colors[0]});
  --chart-2: rgb(${colors[1]});
  --chart-3: rgb(${colors[2]});
  --chart-4: rgb(${colors[3]});
  --chart-5: rgb(${colors[4]});
  --sidebar: var(--background);
  --sidebar-foreground: rgba(255, 255, 255, 0.5);
  --sidebar-primary: oklch(0.488 0.243 264.376);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(var(--primary) / 0.1);
  --sidebar-accent-foreground: oklch(var(--primary));
  --sidebar-border: oklch(1 0 0 / 10%);
  --sidebar-ring: oklch(0.556 0 0);
}
`
    )
    .join("\n")

//  ------------------------------ | CUSTOMIZER - THEME PRESET STYLES | ------------------------------  //

export function ThemePresetStyles() {
  return <style dangerouslySetInnerHTML={{ __html: buildPresetStyles() }} />
}
src/components/customizer/index.tsx
"use client"

import { MouseEvent, useCallback, useEffect, useState } from "react"

// shadcn
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import {
  Sheet,
  SheetContent,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

// third-party
import { useTheme } from "next-themes"

// project-imports
import { RadiusControl } from "./RadiusControl"
// import { ThemePresetButtons } from "./ThemePresetButtons";
import { ThemePresetStyles } from "./ThemePresetStyles"
import { useThemeRadius } from "@/hooks/use-theme-radius"

// assets
import { CircleCheckBig, Cpu, Moon, Settings2, Sun } from "lucide-react"

const THEME_PRESET_KEY = "theme-preset"

const themeClasses = [
  "default",
  "ghibli-studio",
  "perplexity",
  "marvel",
  "material",
  "vscode",
  "datta",
  "flat",
  "guru",
  "berry",
  ...Array.from({ length: 17 }, (_, i) => `preset-${i + 2}`),
]

const customPresetIds = Array.from({ length: 17 }, (_, i) => i + 2)

//  ------------------------------ | CUSTOMIZER - THEME TOGGLE | ------------------------------  //

export function ThemeToggle() {
  const { theme, setTheme } = useTheme()
  const [mounted, setMounted] = useState(false)
  const { radius, setRadius, syncRadiusFromBody } = useThemeRadius()

  useEffect(() => {
    setTimeout(() => setMounted(true), 0)
  }, [])

  useEffect(() => {
    if (mounted) {
      // Restore saved preset class from localStorage on mount
      const savedPreset = localStorage.getItem(THEME_PRESET_KEY)
      const body = document.body
      themeClasses.forEach((cls) => body.classList.remove(cls))

      if (savedPreset && savedPreset !== "default") {
        body.classList.add(savedPreset)
      } else {
        body.classList.add("default")
      }

      // Only sync radius from CSS if there's no saved radius preference
      // (syncRadiusFromBody removes inline --radius which causes a flash)
      if (!localStorage.getItem("theme-radius")) {
        syncRadiusFromBody()
      }
    }
  }, [mounted, syncRadiusFromBody])

  const toggleBodyClass = useCallback(
    (className: string) => {
      const body = document.body
      const isCurrentlyActive = body.classList.contains(className)

      themeClasses.forEach((cls) => body.classList.remove(cls))

      if (!isCurrentlyActive) {
        body.classList.add(className)
        localStorage.setItem(THEME_PRESET_KEY, className)
      } else {
        localStorage.removeItem(THEME_PRESET_KEY)
      }

      syncRadiusFromBody()
    },
    [syncRadiusFromBody]
  )

  const handleThemeChange = useCallback(
    (newTheme: string, event: MouseEvent<HTMLButtonElement>) => {
      if (theme === newTheme) return

      const doc = document as any
      if (
        typeof window === "undefined" ||
        !doc.startViewTransition ||
        window.matchMedia("(prefers-reduced-motion: reduce)").matches
      ) {
        setTheme(newTheme)
        return
      }

      const x = event.clientX
      const y = event.clientY
      const endRadius = Math.hypot(
        Math.max(x, window.innerWidth - x),
        Math.max(y, window.innerHeight - y)
      )

      const transition = doc.startViewTransition(() => {
        setTheme(newTheme)
      })

      transition.ready.then(() => {
        document.documentElement.animate(
          {
            clipPath: [
              `circle(0px at ${x}px ${y}px)`,
              `circle(${endRadius}px at ${x}px ${y}px)`,
            ],
          },
          {
            duration: 500,
            easing: "ease-in-out",
            pseudoElement: "::view-transition-new(root)",
          }
        )
      })
    },
    [theme, setTheme]
  )

  const handlePresetChange = useCallback(
    (presetClass: string, event: MouseEvent<HTMLButtonElement>) => {
      const doc = document as any
      if (
        typeof window === "undefined" ||
        !doc.startViewTransition ||
        window.matchMedia("(prefers-reduced-motion: reduce)").matches
      ) {
        toggleBodyClass(presetClass)
        return
      }

      const x = event.clientX
      const y = event.clientY
      const endRadius = Math.hypot(
        Math.max(x, window.innerWidth - x),
        Math.max(y, window.innerHeight - y)
      )

      const transition = doc.startViewTransition(() => {
        toggleBodyClass(presetClass)
      })

      transition.ready.then(() => {
        document.documentElement.animate(
          {
            clipPath: [
              `circle(0px at ${x}px ${y}px)`,
              `circle(${endRadius}px at ${x}px ${y}px)`,
            ],
          },
          {
            duration: 500,
            easing: "ease-in-out",
            pseudoElement: "::view-transition-new(root)",
          }
        )
      })
    },
    [toggleBodyClass]
  )

  const resetDefault = useCallback(() => {
    setTheme("light")
    themeClasses.forEach((cls) => document.body.classList.remove(cls))
    document.body.classList.add("default")
    localStorage.removeItem(THEME_PRESET_KEY)
    localStorage.removeItem("theme-radius")
    document.body.style.removeProperty("--radius")
    syncRadiusFromBody()
  }, [setTheme, syncRadiusFromBody])

  if (!mounted) {
    return (
      <div className="h-9 w-9 rounded-lg border border-transparent bg-muted/50" />
    )
  }

  return (
    <>
      <ThemePresetStyles />
      <Sheet modal={true}>
        <SheetTrigger
          render={
            <Button
              variant="ghost"
              size="icon"
              aria-label="Theme Customizer"
              className="relative h-9.5 w-9.5 cursor-pointer overflow-hidden rounded-full p-[2px] hover:opacity-90"
            >
              <div className="absolute inset-0 animate-[spin_3s_linear_infinite] bg-[linear-gradient(239.74deg,#C800DE_0%,#9810FA_16.93%,#4680FF_29.63%,#D08700_55.02%,#FF6900_67.72%,#FB2C36_84.65%)]" />
              <div className="relative z-10 flex h-full w-full items-center justify-center rounded-full bg-foreground dark:bg-white">
                <Settings2 className="size-4.5 text-background" />
              </div>
            </Button>
          }
        >
          Open Sheet
        </SheetTrigger>
        <SheetContent className="data-[side=right]:sm:max-w-80 [&~[data-slot='sheet-overlay']]:hidden">
          <SheetHeader>
            <SheetTitle>Settings</SheetTitle>
          </SheetHeader>
          <div className="no-scrollbar overflow-y-auto px-5">
            <div className="flex flex-col gap-6">
              <div className="flex flex-col gap-1">
                <h6>Theme Mode</h6>
                <p className="text-foreground">
                  Choose light, dark, or system mode
                </p>
              </div>
              <div className="grid grid-cols-3 gap-2">
                <Button
                  onClick={(e) => handleThemeChange("light", e)}
                  className="inline-flex flex-col items-center justify-center gap-1 py-4"
                  aria-label="Toggle Theme"
                  variant={theme === "light" ? "default" : "outline"}
                >
                  <Sun className="size-6" />
                  Light
                </Button>
                <Button
                  onClick={(e) => handleThemeChange("dark", e)}
                  className="inline-flex flex-col items-center justify-center gap-1 py-4"
                  aria-label="Toggle Theme"
                  variant={theme === "dark" ? "default" : "outline"}
                >
                  <Moon className="size-6" />
                  Dark
                </Button>
                <Button
                  onClick={(e) => handleThemeChange("system", e)}
                  className="inline-flex flex-col items-center justify-center gap-1 py-4"
                  aria-label="Toggle Theme"
                  variant={theme === "system" ? "default" : "outline"}
                >
                  <Cpu className="size-6" />
                  System
                </Button>
              </div>
              {/* <Separator />
              <div className="hidden">
                <div className="flex flex-col gap-1">
                  <h6>Theme Preset</h6>
                  <p className="text-foreground text-sm">
                    Choose preset mode for theme
                  </p>
                </div>
                <ThemePresetButtons onSelectPreset={toggleBodyClass} />
                <Separator />
              </div> */}
              <RadiusControl radius={radius} onRadiusChange={setRadius} />
              <Separator />
              <div className="flex flex-col gap-1">
                <h6>Custom Theme</h6>
                <p className="text-foreground">
                  Choose your primary theme color
                </p>
              </div>
              <div className="hidden">
                <div className="group-[.preset-10]/body:flex group-[.preset-12]/body:flex group-[.preset-13]/body:flex group-[.preset-14]/body:flex group-[.preset-15]/body:flex group-[.preset-16]/body:flex group-[.preset-17]/body:flex group-[.preset-18]/body:flex group-[.preset-2]/body:flex group-[.preset-3]/body:flex group-[.preset-4]/body:flex group-[.preset-5]/body:flex group-[.preset-6]/body:flex group-[.preset-7]/body:flex group-[.preset-8]/body:flex group-[.preset-9]/body:flex"></div>
              </div>
              <div className="flex flex-wrap gap-2">
                {customPresetIds.map((num) => (
                  <Button
                    variant="link"
                    className="p-0"
                    key={num}
                    data-value="material"
                    onClick={(e) => handlePresetChange(`preset-${num}`, e)}
                  >
                    <div className="flex w-full items-center gap-3">
                      <div className="relative">
                        <span
                          className={`block size-11 rounded-lg bg-primary preset-${num}`}
                        ></span>

                        <div
                          className={`hidden group-[.preset-${num}]/body:flex absolute inset-0 z-10 items-center justify-center text-white`}
                        >
                          <CircleCheckBig className="size-5" />
                        </div>
                      </div>
                    </div>
                  </Button>
                ))}
              </div>
            </div>
          </div>
          <SheetFooter>
            <div className="grid">
              <Button type="button" onClick={resetDefault}>
                Reset Default
              </Button>
            </div>
          </SheetFooter>
        </SheetContent>
      </Sheet>
    </>
  )
}
src/components/uiable/blocks/landing/components/NavSearchDialog.tsx
"use client"

import { useEffect, useState } from "react"

// next
import { useRouter } from "next/navigation"

// shadcn
import {
  Command,
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"

// project-imports
import { NAV_COMPONENTS, NAV_BLOCKS, NAV_DOCS } from "@/components-grid"

// assets
import {
  IconComponents,
  IconCreditCard,
  IconGauge,
  IconLayoutDashboard,
  IconSparkles,
  IconTemplate,
  type Icon as TablerIcon,
} from "@tabler/icons-react"

// types
interface NavSearchDialogProps {
  open: boolean
  setOpen: (open: boolean) => void
}

interface CommandResultItemProps {
  icon: TablerIcon
  value: string
  title: string
  onSelect: () => void
}

// ------------------------------ | NAVBAR SEARCH DIALOG | ------------------------------  //

function CommandResultItem({
  icon: Icon,
  value,
  title,
  onSelect,
}: CommandResultItemProps) {
  return (
    <CommandItem value={value} onSelect={onSelect}>
      <Icon aria-hidden="true" className="size-4" />
      <span>{title}</span>
    </CommandItem>
  )
}

export default function NavSearchDialog({
  open,
  setOpen,
}: NavSearchDialogProps) {
  const router = useRouter()
  const [search, setSearch] = useState("")
  const [prevOpen, setPrevOpen] = useState(open)

  if (open !== prevOpen) {
    setPrevOpen(open)
    if (!open) {
      setSearch("")
    }
  }

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault()
        setOpen(!open)
      }
    }
    document.addEventListener("keydown", down)
    return () => document.removeEventListener("keydown", down)
  }, [open, setOpen])

  const runCommand = (command: () => void) => {
    setOpen(false)
    command()
  }

  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <Command>
        <CommandInput
          placeholder="Type a command or search..."
          value={search}
          onValueChange={setSearch}
        />
        <CommandList>
          {search.length > 0 ? (
            <>
              <CommandEmpty>No results found.</CommandEmpty>
              <CommandGroup heading="Documentation">
                {NAV_DOCS.map((doc) => (
                  <CommandResultItem
                    key={doc.slug}
                    icon={IconSparkles}
                    value={doc.title}
                    title={doc.title}
                    onSelect={() =>
                      runCommand(() => router.push(`/doc/${doc.slug}`))
                    }
                  />
                ))}
              </CommandGroup>
              {NAV_COMPONENTS.map((section) => (
                <CommandGroup key={section.title} heading={section.title}>
                  {section.items.map((item) => (
                    <CommandResultItem
                      key={item.slug}
                      icon={IconComponents}
                      value={item.title}
                      title={item.title}
                      onSelect={() =>
                        runCommand(() =>
                          router.push(`/components/${item.slug}`)
                        )
                      }
                    />
                  ))}
                </CommandGroup>
              ))}
              {NAV_BLOCKS.map((section) => (
                <CommandGroup
                  key={`block-${section.title}`}
                  heading={`Blocks - ${section.title}`}
                >
                  {section.items.map((item) => (
                    <CommandResultItem
                      key={item.slug}
                      icon={IconLayoutDashboard}
                      value={`${item.title} block`}
                      title={item.title}
                      onSelect={() =>
                        runCommand(() => router.push(`/blocks/${item.slug}`))
                      }
                    />
                  ))}
                </CommandGroup>
              ))}
            </>
          ) : (
            <CommandGroup heading="Suggestions">
              <CommandResultItem
                icon={IconSparkles}
                value="Introduction"
                title={NAV_DOCS[0].title}
                onSelect={() =>
                  runCommand(() => router.push(`/doc/${NAV_DOCS[0].slug}`))
                }
              />
              <CommandResultItem
                icon={IconComponents}
                value="Components"
                title="Components"
                onSelect={() => runCommand(() => router.push("/components"))}
              />
              <CommandResultItem
                icon={IconLayoutDashboard}
                value="Blocks"
                title="Blocks"
                onSelect={() => runCommand(() => router.push("/blocks"))}
              />
              <CommandResultItem
                icon={IconTemplate}
                value="Templates"
                title="Templates"
                onSelect={() => runCommand(() => router.push("/templates"))}
              />
              <CommandResultItem
                icon={IconGauge}
                value="Dashboards"
                title="Dashboards"
                onSelect={() =>
                  runCommand(() => router.push("/admin-dashboard"))
                }
              />
              <CommandResultItem
                icon={IconCreditCard}
                value="Pricing"
                title="Pricing"
                onSelect={() => runCommand(() => router.push("/pricing"))}
              />
            </CommandGroup>
          )}
        </CommandList>
      </Command>
    </CommandDialog>
  )
}
src/hooks/use-github-star-count.ts
"use client"

import { useEffect, useState } from "react"

// project-imports
import { formatStarCount } from "@/utils/github"

//  ------------------------------ | HOOK - USE GITHUB STAR COUNT | ------------------------------  //

const GITHUB_STARS_FALLBACK = "122"

export function useGithubStarCount() {
  const [stars, setStars] = useState(GITHUB_STARS_FALLBACK)

  useEffect(() => {
    const controller = new AbortController()

    fetch("/api/github-stars", {
      signal: controller.signal,
      cache: "no-store",
    })
      .then((res) => (res.ok ? res.json() : Promise.reject(res)))
      .then((data: { count: number | null }) => {
        if (typeof data.count === "number") {
          setStars(formatStarCount(data.count))
        }
      })
      .catch(() => {
        // Keep the fallback count if the live count can't be fetched.
      })

    return () => controller.abort()
  }, [])

  return stars
}
src/hooks/use-theme-radius.ts
"use client"

import { useCallback, useEffect, useState } from "react"

const parseCssRadius = (value: string) => {
  const normalized = value.trim().toLowerCase()
  if (!normalized) return NaN
  if (normalized.endsWith("rem")) return parseFloat(normalized)
  if (normalized.endsWith("px")) {
    const px = parseFloat(normalized)
    const rootFontSize =
      parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
    return px / rootFontSize
  }
  return parseFloat(normalized)
}

const getCssRootRadius = () => {
  if (typeof window === "undefined") return null
  const cssRadius = getComputedStyle(document.documentElement).getPropertyValue(
    "--radius"
  )
  const parsedRadius = parseCssRadius(cssRadius)
  return Number.isNaN(parsedRadius) ? null : parsedRadius
}

//  ------------------------------ | CUSTOMIZER - USE THEME RADIUS | ------------------------------  //

export function useThemeRadius() {
  const [radius, setRadius] = useState<number>(() => {
    if (typeof window === "undefined") return 0.3
    return getCssRootRadius() ?? 0.3
  })

  const syncRadiusFromBody = useCallback(() => {
    const body = document.body
    body.style.removeProperty("--radius")

    const cssRadius = getComputedStyle(body).getPropertyValue("--radius")
    const nextRadius = parseCssRadius(cssRadius)

    if (!Number.isNaN(nextRadius)) {
      setRadius(nextRadius)
    }
  }, [])

  useEffect(() => {
    document.body.style.setProperty("--radius", `${radius}rem`)
  }, [radius])

  return { radius, setRadius, syncRadiusFromBody }
}
src/images/svg/icons/star.tsx
// types
import type { SVGAttributes } from "react"

//  ------------------------------ | STAR ICON | ------------------------------  //

export default function Star(props: SVGAttributes<SVGElement>) {
  return (
    <svg
      width="18"
      height="19"
      viewBox="0 0 18 19"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
      {...props}
    >
      <path
        d="M9 0L9.88653 2.63808C10.9094 5.6818 13.3316 8.04921 16.3979 9.00213L18 9.5L16.3979 9.99787C13.3316 10.9508 10.9094 13.3182 9.88653 16.3619L9 19L8.71227 17.5787C7.96008 13.8632 5.17156 10.8924 1.51099 9.90683L0 9.5L1.51099 9.09317C5.17156 8.10757 7.96007 5.13681 8.71227 1.42126L9 0Z"
        fill="currentColor"
      />
    </svg>
  )
}
src/lib/auth/last-auth-method.ts
import { useSyncExternalStore } from "react"

// third-party
import type { User } from "@supabase/supabase-js"

//  ------------------------------ | AUTH - LAST AUTH METHOD | ------------------------------  //

// Tracks which social provider a user most recently signed in with on this
// browser, so the login page can show a "Last used" badge on that button.
// Only OAuth providers are tracked — an email/password sign-in leaves the
// stored value untouched, since there's no matching badge to show for it.

const STORAGE_KEY = "uiable-last-auth-method"
const CHANGE_EVENT = "uiable-last-auth-method-change"
const TRACKED_PROVIDERS = new Set(["google", "github"])

function readLastAuthMethod(): string | null {
  try {
    const value = window.localStorage.getItem(STORAGE_KEY)
    return value && TRACKED_PROVIDERS.has(value) ? value : null
  } catch {
    return null
  }
}

function subscribe(onChange: () => void) {
  window.addEventListener(CHANGE_EVENT, onChange)
  return () => window.removeEventListener(CHANGE_EVENT, onChange)
}

// Client-only — reads via useSyncExternalStore so the server/first-paint
// snapshot (undefined) matches across hydration, then updates once mounted.
export function useLastAuthMethod() {
  return useSyncExternalStore<string | null | undefined>(
    subscribe,
    readLastAuthMethod,
    () => undefined
  )
}

// Called whenever auth-nav.tsx confirms an authenticated session (on mount
// and on every auth state change), so the badge always reflects the
// provider that is genuinely signing the user in right now.
export function rememberLastAuthMethod(user: User): void {
  const provider = user.app_metadata?.provider as string | undefined
  if (!provider || !TRACKED_PROVIDERS.has(provider)) return
  try {
    window.localStorage.setItem(STORAGE_KEY, provider)
    window.dispatchEvent(new Event(CHANGE_EVENT))
  } catch {
    // Ignore — e.g. private browsing with storage disabled.
  }
}
src/lib/supabase/client.ts
"use client"

// third-party
import { createBrowserClient } from "@supabase/ssr"

// project-imports
import { supabasePublishableKey, supabaseUrl } from "@/lib/supabase/config"

// Browser-side Supabase client. Call only after checking isSupabaseConfigured()
// (the auth pages do) — throws when the env vars are missing.
export function createClient() {
  if (!supabaseUrl || !supabasePublishableKey) {
    throw new Error(
      "Supabase is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY."
    )
  }
  return createBrowserClient(supabaseUrl, supabasePublishableKey)
}
src/lib/supabase/config.ts
// Central check for Supabase configuration. When the env vars are absent the
// site runs in public/catalog-only mode: auth UI shows a notice, the proxy
// skips session handling, and nothing crashes for local component work.
//
// Key naming: Supabase's current scheme is publishable (sb_publishable_...)
// and secret (sb_secret_...) keys; the legacy anon / service_role JWT names
// are accepted as fallbacks for older projects.

export const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL

export const supabasePublishableKey =
  process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY

export function isSupabaseConfigured(): boolean {
  return Boolean(supabaseUrl && supabasePublishableKey)
}
src/utils/github.ts
//  ------------------------------ | UTILS - GITHUB | ------------------------------  //

const GITHUB_STARGAZERS_COUNT_URL =
  "https://api.github.com/repos/codedthemes/uiable/stargazers/count"

export async function fetchGithubStarCount(): Promise<number | null> {
  try {
    const res = await fetch(GITHUB_STARGAZERS_COUNT_URL, {
      next: { revalidate: 3600 },
    })
    if (!res.ok) return null

    const data = (await res.json()) as { count: number }
    return typeof data.count === "number" ? data.count : null
  } catch (error) {
    console.error("[github] Failed to fetch star count:", error)
    return null
  }
}

export function formatStarCount(count: number): string {
  return new Intl.NumberFormat("en-US", { notation: "compact" }).format(count)
}

More from uiable

Browse all components in this library →