mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-29 02:46:25 +02:00
feat: implement user settings page with profile and API key management components
This commit is contained in:
parent
97fbb70672
commit
77dc6b7c91
10 changed files with 785 additions and 531 deletions
540
surfsense_web/components/ui/animated-tabs.tsx
Normal file
540
surfsense_web/components/ui/animated-tabs.tsx
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue