feat: implement user settings page with profile and API key management components

This commit is contained in:
Anish Sarkar 2026-03-08 19:36:12 +05:30
parent 97fbb70672
commit 77dc6b7c91
10 changed files with 785 additions and 531 deletions

View file

@ -0,0 +1,540 @@
"use client"
import React, {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react"
import { cn } from "@/lib/utils"
/*
Context (replaces cloneElement)
*/
interface TabsContextValue {
activeValue: string
onValueChange: (value: string) => void
}
const TabsContext = createContext<TabsContextValue | null>(null)
function useTabsContext() {
const ctx = useContext(TabsContext)
if (!ctx) {
throw new Error(
"AnimatedTabs compound components must be rendered inside <Tabs>"
)
}
return ctx
}
/*
Constants (hoisted out of render)
*/
const SIZE_CLASSES = {
sm: "h-[32px] text-sm",
md: "h-[40px] text-base",
lg: "h-[48px] text-lg",
} as const
const VARIANT_CLASSES = {
default: "",
pills: "rounded-full",
underlined: "",
} as const
const ACTIVE_INDICATOR_CLASSES = {
default: "h-[4px] bg-primary dark:bg-primary",
pills: "hidden",
underlined: "h-[4px] bg-primary dark:bg-primary",
} as const
const HOVER_INDICATOR_CLASSES = {
default: "bg-muted dark:bg-muted rounded-[6px]",
pills: "bg-muted dark:bg-muted rounded-full",
underlined: "bg-muted dark:bg-muted rounded-[6px]",
} as const
/*
XScrollable (internal)
*/
const XScrollable = forwardRef<
HTMLDivElement,
{
className?: string
children?: ReactNode
showScrollbar?: boolean
contentClassName?: string
} & React.HTMLAttributes<HTMLDivElement>
>(({ className, children, showScrollbar = true, contentClassName, ...props }, ref) => {
const scrollRef = useRef<HTMLDivElement | null>(null)
const dragging = useRef(false)
const startX = useRef(0)
const startScrollLeft = useRef(0)
const onMouseDown = (e: React.MouseEvent) => {
if (!scrollRef.current) return
dragging.current = true
startX.current = e.clientX
startScrollLeft.current = scrollRef.current.scrollLeft
}
const endDrag = () => {
dragging.current = false
}
const onMouseMove = (e: React.MouseEvent) => {
if (!dragging.current || !scrollRef.current) return
e.preventDefault()
const dx = e.clientX - startX.current
scrollRef.current.scrollLeft = startScrollLeft.current - dx
}
const onWheel = (e: React.WheelEvent) => {
if (!scrollRef.current) return
const delta =
Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX
if (delta !== 0) {
e.preventDefault()
scrollRef.current.scrollLeft += delta
}
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events
<div
ref={ref}
className={cn("relative", className)}
{...props}
onMouseLeave={endDrag}
onMouseUp={endDrag}
onMouseMove={onMouseMove}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll requires onMouseDown */}
<div
ref={scrollRef}
className={cn(
"overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
!showScrollbar && "scrollbar-none",
contentClassName
)}
onWheel={onWheel}
onMouseDown={onMouseDown}
>
{children}
</div>
</div>
)
})
XScrollable.displayName = "XScrollable"
/*
Tabs (root)
*/
const Tabs = forwardRef<
HTMLDivElement,
{
defaultValue?: string
value?: string
onValueChange?: (value: string) => void
className?: string
children?: ReactNode
}
>(
(
{ defaultValue, value, onValueChange, className, children, ...props },
ref
) => {
const [activeValue, setActiveValue] = useState(value || defaultValue || "")
useEffect(() => {
if (value !== undefined) {
setActiveValue(value)
}
}, [value])
const handleValueChange = useCallback(
(newValue: string) => {
if (value === undefined) {
setActiveValue(newValue)
}
onValueChange?.(newValue)
},
[onValueChange, value]
)
return (
<TabsContext.Provider
value={{ activeValue, onValueChange: handleValueChange }}
>
<div ref={ref} className={cn("tabs-container", className)} {...props}>
{children}
</div>
</TabsContext.Provider>
)
}
)
Tabs.displayName = "Tabs"
/*
TabsList
*/
type TabsListVariant = "default" | "pills" | "underlined"
type TabsListSize = "sm" | "md" | "lg"
const TabsList = forwardRef<
HTMLDivElement,
{
className?: string
children?: ReactNode
showHoverEffect?: boolean
showActiveIndicator?: boolean
activeIndicatorPosition?: "top" | "bottom"
activeIndicatorOffset?: number
size?: TabsListSize
variant?: TabsListVariant
stretch?: boolean
ariaLabel?: string
showBottomBorder?: boolean
bottomBorderClassName?: string
activeIndicatorClassName?: string
hoverIndicatorClassName?: string
}
>(
(
{
className,
children,
showHoverEffect = true,
showActiveIndicator = true,
activeIndicatorPosition = "bottom",
activeIndicatorOffset = 0,
size = "sm",
variant = "default",
stretch = false,
ariaLabel = "Tabs",
showBottomBorder = false,
bottomBorderClassName,
activeIndicatorClassName,
hoverIndicatorClassName,
...props
},
ref
) => {
const { activeValue, onValueChange } = useTabsContext()
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const [hoverStyle, setHoverStyle] = useState({})
const [activeStyle, setActiveStyle] = useState({
left: "0px",
width: "0px",
})
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const activeIndex = React.Children.toArray(children).findIndex(
(child) =>
React.isValidElement(child) &&
(child as React.ReactElement<{ value: string }>).props.value ===
activeValue
)
useEffect(() => {
if (hoveredIndex !== null && showHoverEffect) {
const hoveredElement = tabRefs.current[hoveredIndex]
if (hoveredElement) {
const { offsetLeft, offsetWidth } = hoveredElement
setHoverStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
})
}
}
}, [hoveredIndex, showHoverEffect])
const updateActiveIndicator = useCallback(() => {
if (showActiveIndicator && activeIndex >= 0) {
const activeElement = tabRefs.current[activeIndex]
if (activeElement) {
const { offsetLeft, offsetWidth } = activeElement
setActiveStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
})
}
}
}, [showActiveIndicator, activeIndex])
useEffect(() => {
updateActiveIndicator()
}, [updateActiveIndicator])
useEffect(() => {
requestAnimationFrame(updateActiveIndicator)
}, [updateActiveIndicator])
const scrollTabToCenter = useCallback((index: number) => {
const tabElement = tabRefs.current[index]
const scrollContainer = scrollContainerRef.current
if (tabElement && scrollContainer) {
const containerWidth = scrollContainer.offsetWidth
const tabWidth = tabElement.offsetWidth
const tabLeft = tabElement.offsetLeft
const scrollTarget = tabLeft - containerWidth / 2 + tabWidth / 2
scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" })
}
}, [])
const setTabRef = useCallback(
(el: HTMLDivElement | null, index: number) => {
tabRefs.current[index] = el
},
[]
)
const handleScrollableRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
const scrollableDiv = node.querySelector(
'div[class*="overflow-x-auto"]'
)
if (scrollableDiv) {
scrollContainerRef.current = scrollableDiv as HTMLDivElement
}
}
}, [])
useEffect(() => {
if (activeIndex >= 0) {
const timer = setTimeout(() => {
scrollTabToCenter(activeIndex)
}, 100)
return () => clearTimeout(timer)
}
}, [activeIndex, scrollTabToCenter])
return (
<div
ref={handleScrollableRef}
className={cn("relative", className)}
role="tablist"
aria-label={ariaLabel}
{...props}
>
<XScrollable showScrollbar={false}>
<div className={cn("relative", showBottomBorder && "pb-px")}>
{showBottomBorder && (
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-px bg-border dark:bg-border",
bottomBorderClassName
)}
/>
)}
{showHoverEffect && (
<div
className={cn(
"absolute transition-all duration-300 ease-out flex items-center z-0",
SIZE_CLASSES[size],
HOVER_INDICATOR_CLASSES[variant],
hoverIndicatorClassName
)}
style={{
...hoverStyle,
opacity: hoveredIndex !== null ? 1 : 0,
transition: "all 300ms ease-out",
}}
aria-hidden="true"
/>
)}
<div
ref={ref}
className={cn(
"relative flex items-center",
stretch ? "w-full" : "",
variant === "default" ? "space-x-[6px]" : "space-x-[2px]"
)}
>
{React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return child
const childProps = (
child as React.ReactElement<{
value: string
disabled?: boolean
label?: string
className?: string
activeClassName?: string
inactiveClassName?: string
disabledClassName?: string
}>
).props
const { value, disabled } = childProps
const isActive = value === activeValue
return (
<div
key={value}
ref={(el) => setTabRef(el, index)}
className={cn(
"px-3 py-2 sm:mb-1.5 mb-2 cursor-pointer transition-colors duration-300",
SIZE_CLASSES[size],
variant === "pills" && isActive
? "bg-[#0e0f1114] dark:bg-[#ffffff1a] rounded-full"
: "",
disabled ? "opacity-50 cursor-not-allowed" : "",
stretch ? "flex-1 text-center" : "",
isActive
? childProps.activeClassName ||
"text-foreground dark:text-foreground"
: childProps.inactiveClassName ||
"text-muted-foreground dark:text-muted-foreground",
disabled && childProps.disabledClassName,
VARIANT_CLASSES[variant],
childProps.className
)}
onMouseEnter={() => !disabled && setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => {
if (!disabled) {
onValueChange(value)
scrollTabToCenter(index)
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
if (!disabled) {
onValueChange(value)
scrollTabToCenter(index)
}
}
}}
role="tab"
aria-selected={isActive}
aria-disabled={disabled}
aria-controls={`tabpanel-${value}`}
id={`tab-${value}`}
tabIndex={isActive ? 0 : -1}
>
<div className="whitespace-nowrap flex items-center justify-center h-full">
{child}
</div>
</div>
)
})}
</div>
{showActiveIndicator && variant !== "pills" && activeIndex >= 0 && (
<div
className={cn(
"absolute transition-all duration-300 ease-out z-10",
ACTIVE_INDICATOR_CLASSES[variant],
activeIndicatorPosition === "top"
? "top-[-1px]"
: "bottom-[-1px]",
activeIndicatorClassName
)}
style={{
...activeStyle,
transition: "all 300ms ease-out",
[activeIndicatorPosition]: `${activeIndicatorOffset}px`,
}}
aria-hidden="true"
/>
)}
</div>
</XScrollable>
</div>
)
}
)
TabsList.displayName = "TabsList"
/*
TabsTrigger
*/
const TabsTrigger = forwardRef<
HTMLDivElement,
{
value: string
disabled?: boolean
label?: string
className?: string
activeClassName?: string
inactiveClassName?: string
disabledClassName?: string
children?: ReactNode
}
>(
(
{
value,
disabled = false,
label,
className,
activeClassName,
inactiveClassName,
disabledClassName,
children,
...props
},
ref
) => {
return (
<div ref={ref} className={cn("flex items-center", className)} {...props}>
{label || children}
</div>
)
}
)
TabsTrigger.displayName = "TabsTrigger"
/*
TabsContent
*/
const TabsContent = forwardRef<
HTMLDivElement,
{
value: string
className?: string
children: ReactNode
}
>(
(
{ value, className, children, ...props },
ref
) => {
const { activeValue } = useTabsContext()
if (value !== activeValue) return null
return (
<div
ref={ref}
role="tabpanel"
id={`tabpanel-${value}`}
aria-labelledby={`tab-${value}`}
className={className}
{...props}
>
{children}
</div>
)
}
)
TabsContent.displayName = "TabsContent"
export { Tabs, TabsList, TabsTrigger, TabsContent }