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 · lighticonslucide-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)
}