summaryrefslogtreecommitdiff
path: root/jb-ui/src/components/ui
diff options
context:
space:
mode:
Diffstat (limited to 'jb-ui/src/components/ui')
-rw-r--r--jb-ui/src/components/ui/button.tsx64
-rw-r--r--jb-ui/src/components/ui/checkbox.tsx30
-rw-r--r--jb-ui/src/components/ui/confetti.tsx146
-rw-r--r--jb-ui/src/components/ui/dot-pattern.tsx156
-rw-r--r--jb-ui/src/components/ui/field.tsx246
-rw-r--r--jb-ui/src/components/ui/label.tsx22
-rw-r--r--jb-ui/src/components/ui/magic-card.tsx101
-rw-r--r--jb-ui/src/components/ui/pagination.tsx127
-rw-r--r--jb-ui/src/components/ui/separator.tsx28
-rw-r--r--jb-ui/src/components/ui/skeleton.tsx13
10 files changed, 933 insertions, 0 deletions
diff --git a/jb-ui/src/components/ui/button.tsx b/jb-ui/src/components/ui/button.tsx
new file mode 100644
index 0000000..b5ea4ab
--- /dev/null
+++ b/jb-ui/src/components/ui/button.tsx
@@ -0,0 +1,64 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps<typeof buttonVariants> & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+ <Comp
+ data-slot="button"
+ data-variant={variant}
+ data-size={size}
+ className={cn(buttonVariants({ variant, size, className }))}
+ {...props}
+ />
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/jb-ui/src/components/ui/checkbox.tsx b/jb-ui/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..0e2a6cd
--- /dev/null
+++ b/jb-ui/src/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
+ return (
+ <CheckboxPrimitive.Root
+ data-slot="checkbox"
+ className={cn(
+ "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ data-slot="checkbox-indicator"
+ className="grid place-content-center text-current transition-none"
+ >
+ <CheckIcon className="size-3.5" />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+ )
+}
+
+export { Checkbox }
diff --git a/jb-ui/src/components/ui/confetti.tsx b/jb-ui/src/components/ui/confetti.tsx
new file mode 100644
index 0000000..e0d353a
--- /dev/null
+++ b/jb-ui/src/components/ui/confetti.tsx
@@ -0,0 +1,146 @@
+import type { ReactNode } from "react"
+import React, {
+ createContext,
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+} from "react"
+import type {
+ GlobalOptions as ConfettiGlobalOptions,
+ CreateTypes as ConfettiInstance,
+ Options as ConfettiOptions,
+} from "canvas-confetti"
+import confetti from "canvas-confetti"
+
+import { Button } from "@/components/ui/button"
+
+type Api = {
+ fire: (options?: ConfettiOptions) => void
+}
+
+type Props = React.ComponentPropsWithRef<"canvas"> & {
+ options?: ConfettiOptions
+ globalOptions?: ConfettiGlobalOptions
+ manualstart?: boolean
+ children?: ReactNode
+}
+
+export type ConfettiRef = Api | null
+
+const ConfettiContext = createContext<Api>({} as Api)
+
+// Define component first
+const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
+ const {
+ options,
+ globalOptions = { resize: true, useWorker: true },
+ manualstart = false,
+ children,
+ ...rest
+ } = props
+ const instanceRef = useRef<ConfettiInstance | null>(null)
+
+ const canvasRef = useCallback(
+ (node: HTMLCanvasElement) => {
+ if (node !== null) {
+ if (instanceRef.current) return
+ instanceRef.current = confetti.create(node, {
+ ...globalOptions,
+ resize: true,
+ })
+ } else {
+ if (instanceRef.current) {
+ instanceRef.current.reset()
+ instanceRef.current = null
+ }
+ }
+ },
+ [globalOptions]
+ )
+
+ const fire = useCallback(
+ async (opts = {}) => {
+ try {
+ await instanceRef.current?.({ ...options, ...opts })
+ } catch (error) {
+ console.error("Confetti error:", error)
+ }
+ },
+ [options]
+ )
+
+ const api = useMemo(
+ () => ({
+ fire,
+ }),
+ [fire]
+ )
+
+ useImperativeHandle(ref, () => api, [api])
+
+ useEffect(() => {
+ if (!manualstart) {
+ ;(async () => {
+ try {
+ await fire()
+ } catch (error) {
+ console.error("Confetti effect error:", error)
+ }
+ })()
+ }
+ }, [manualstart, fire])
+
+ return (
+ <ConfettiContext.Provider value={api}>
+ <canvas ref={canvasRef} {...rest} />
+ {children}
+ </ConfettiContext.Provider>
+ )
+})
+
+// Set display name immediately
+ConfettiComponent.displayName = "Confetti"
+
+// Export as Confetti
+export const Confetti = ConfettiComponent
+
+interface ConfettiButtonProps extends React.ComponentProps<"button"> {
+ options?: ConfettiOptions &
+ ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }
+}
+
+const ConfettiButtonComponent = ({
+ options,
+ children,
+ ...props
+}: ConfettiButtonProps) => {
+ const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
+ try {
+ const rect = event.currentTarget.getBoundingClientRect()
+ const x = rect.left + rect.width / 2
+ const y = rect.top + rect.height / 2
+ await confetti({
+ ...options,
+ origin: {
+ x: x / window.innerWidth,
+ y: y / window.innerHeight,
+ },
+ })
+ } catch (error) {
+ console.error("Confetti button error:", error)
+ }
+ }
+
+ return (
+ <Button onClick={handleClick} {...props}>
+ {children}
+ </Button>
+ )
+}
+
+ConfettiButtonComponent.displayName = "ConfettiButton"
+
+export const ConfettiButton = ConfettiButtonComponent
diff --git a/jb-ui/src/components/ui/dot-pattern.tsx b/jb-ui/src/components/ui/dot-pattern.tsx
new file mode 100644
index 0000000..9522b7f
--- /dev/null
+++ b/jb-ui/src/components/ui/dot-pattern.tsx
@@ -0,0 +1,156 @@
+import React, { useEffect, useId, useRef, useState } from "react"
+import { motion } from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+/**
+ * DotPattern Component Props
+ *
+ * @param {number} [width=16] - The horizontal spacing between dots
+ * @param {number} [height=16] - The vertical spacing between dots
+ * @param {number} [x=0] - The x-offset of the entire pattern
+ * @param {number} [y=0] - The y-offset of the entire pattern
+ * @param {number} [cx=1] - The x-offset of individual dots
+ * @param {number} [cy=1] - The y-offset of individual dots
+ * @param {number} [cr=1] - The radius of each dot
+ * @param {string} [className] - Additional CSS classes to apply to the SVG container
+ * @param {boolean} [glow=false] - Whether dots should have a glowing animation effect
+ */
+interface DotPatternProps extends React.SVGProps<SVGSVGElement> {
+ width?: number
+ height?: number
+ x?: number
+ y?: number
+ cx?: number
+ cy?: number
+ cr?: number
+ className?: string
+ glow?: boolean
+ [key: string]: unknown
+}
+
+/**
+ * DotPattern Component
+ *
+ * A React component that creates an animated or static dot pattern background using SVG.
+ * The pattern automatically adjusts to fill its container and can optionally display glowing dots.
+ *
+ * @component
+ *
+ * @see DotPatternProps for the props interface.
+ *
+ * @example
+ * // Basic usage
+ * <DotPattern />
+ *
+ * // With glowing effect and custom spacing
+ * <DotPattern
+ * width={20}
+ * height={20}
+ * glow={true}
+ * className="opacity-50"
+ * />
+ *
+ * @notes
+ * - The component is client-side only ("use client")
+ * - Automatically responds to container size changes
+ * - When glow is enabled, dots will animate with random delays and durations
+ * - Uses Motion for animations
+ * - Dots color can be controlled via the text color utility classes
+ */
+
+export function DotPattern({
+ width = 16,
+ height = 16,
+ x = 0,
+ y = 0,
+ cx = 1,
+ cy = 1,
+ cr = 1,
+ className,
+ glow = false,
+ ...props
+}: DotPatternProps) {
+ const id = useId()
+ const containerRef = useRef<SVGSVGElement>(null)
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
+
+ useEffect(() => {
+ const updateDimensions = () => {
+ if (containerRef.current) {
+ const { width, height } = containerRef.current.getBoundingClientRect()
+ setDimensions({ width, height })
+ }
+ }
+
+ updateDimensions()
+ window.addEventListener("resize", updateDimensions)
+ return () => window.removeEventListener("resize", updateDimensions)
+ }, [])
+
+ const dots = Array.from(
+ {
+ length:
+ Math.ceil(dimensions.width / width) *
+ Math.ceil(dimensions.height / height),
+ },
+ (_, i) => {
+ const col = i % Math.ceil(dimensions.width / width)
+ const row = Math.floor(i / Math.ceil(dimensions.width / width))
+ return {
+ x: col * width + cx,
+ y: row * height + cy,
+ delay: Math.random() * 5,
+ duration: Math.random() * 3 + 2,
+ }
+ }
+ )
+
+ return (
+ <svg
+ ref={containerRef}
+ aria-hidden="true"
+ className={cn(
+ "pointer-events-none absolute inset-0 h-full w-full text-neutral-400/80",
+ className
+ )}
+ {...props}
+ >
+ <defs>
+ <radialGradient id={`${id}-gradient`}>
+ <stop offset="0%" stopColor="currentColor" stopOpacity="1" />
+ <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
+ </radialGradient>
+ </defs>
+ {dots.map((dot) => (
+ <motion.circle
+ key={`${dot.x}-${dot.y}`}
+ cx={dot.x}
+ cy={dot.y}
+ r={cr}
+ fill={glow ? `url(#${id}-gradient)` : "currentColor"}
+ initial={glow ? { opacity: 0.4, scale: 1 } : {}}
+ animate={
+ glow
+ ? {
+ opacity: [0.4, 1, 0.4],
+ scale: [1, 1.5, 1],
+ }
+ : {}
+ }
+ transition={
+ glow
+ ? {
+ duration: dot.duration,
+ repeat: Infinity,
+ repeatType: "reverse",
+ delay: dot.delay,
+ ease: "easeInOut",
+ }
+ : {}
+ }
+ />
+ ))}
+ </svg>
+ )
+}
diff --git a/jb-ui/src/components/ui/field.tsx b/jb-ui/src/components/ui/field.tsx
new file mode 100644
index 0000000..db0dc12
--- /dev/null
+++ b/jb-ui/src/components/ui/field.tsx
@@ -0,0 +1,246 @@
+import { useMemo } from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+import { Separator } from "@/components/ui/separator"
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+ <fieldset
+ data-slot="field-set"
+ className={cn(
+ "flex flex-col gap-6",
+ "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldLegend({
+ className,
+ variant = "legend",
+ ...props
+}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
+ return (
+ <legend
+ data-slot="field-legend"
+ data-variant={variant}
+ className={cn(
+ "mb-3 font-medium",
+ "data-[variant=legend]:text-base",
+ "data-[variant=label]:text-sm",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-group"
+ className={cn(
+ "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const fieldVariants = cva(
+ "group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
+ {
+ variants: {
+ orientation: {
+ vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
+ horizontal: [
+ "flex-row items-center",
+ "[&>[data-slot=field-label]]:flex-auto",
+ "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ ],
+ responsive: [
+ "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
+ "@md/field-group:[&>[data-slot=field-label]]:flex-auto",
+ "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ ],
+ },
+ },
+ defaultVariants: {
+ orientation: "vertical",
+ },
+ }
+)
+
+function Field({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
+ return (
+ <div
+ role="group"
+ data-slot="field"
+ data-orientation={orientation}
+ className={cn(fieldVariants({ orientation }), className)}
+ {...props}
+ />
+ )
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-content"
+ className={cn(
+ "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps<typeof Label>) {
+ return (
+ <Label
+ data-slot="field-label"
+ className={cn(
+ "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
+ "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-label"
+ className={cn(
+ "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ <p
+ data-slot="field-description"
+ className={cn(
+ "text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
+ "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
+ "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode
+}) {
+ return (
+ <div
+ data-slot="field-separator"
+ data-content={!!children}
+ className={cn(
+ "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
+ className
+ )}
+ {...props}
+ >
+ <Separator className="absolute inset-0 top-1/2" />
+ {children && (
+ <span
+ className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
+ data-slot="field-separator-content"
+ >
+ {children}
+ </span>
+ )}
+ </div>
+ )
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children
+ }
+
+ if (!errors?.length) {
+ return null
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ]
+
+ if (uniqueErrors?.length == 1) {
+ return uniqueErrors[0]?.message
+ }
+
+ return (
+ <ul className="ml-4 flex list-disc flex-col gap-1">
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && <li key={index}>{error.message}</li>
+ )}
+ </ul>
+ )
+ }, [children, errors])
+
+ if (!content) {
+ return null
+ }
+
+ return (
+ <div
+ role="alert"
+ data-slot="field-error"
+ className={cn("text-destructive text-sm font-normal", className)}
+ {...props}
+ >
+ {content}
+ </div>
+ )
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+}
diff --git a/jb-ui/src/components/ui/label.tsx b/jb-ui/src/components/ui/label.tsx
new file mode 100644
index 0000000..ef7133a
--- /dev/null
+++ b/jb-ui/src/components/ui/label.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+ return (
+ <LabelPrimitive.Root
+ data-slot="label"
+ className={cn(
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Label }
diff --git a/jb-ui/src/components/ui/magic-card.tsx b/jb-ui/src/components/ui/magic-card.tsx
new file mode 100644
index 0000000..e6503d8
--- /dev/null
+++ b/jb-ui/src/components/ui/magic-card.tsx
@@ -0,0 +1,101 @@
+import React, { useCallback, useEffect } from "react"
+import { motion, useMotionTemplate, useMotionValue } from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+interface MagicCardProps {
+ children?: React.ReactNode
+ className?: string
+ gradientSize?: number
+ gradientColor?: string
+ gradientOpacity?: number
+ gradientFrom?: string
+ gradientTo?: string
+}
+
+export function MagicCard({
+ children,
+ className,
+ gradientSize = 200,
+ gradientColor = "#262626",
+ gradientOpacity = 0.8,
+ gradientFrom = "#9E7AFF",
+ gradientTo = "#FE8BBB",
+}: MagicCardProps) {
+ const mouseX = useMotionValue(-gradientSize)
+ const mouseY = useMotionValue(-gradientSize)
+ const reset = useCallback(() => {
+ mouseX.set(-gradientSize)
+ mouseY.set(-gradientSize)
+ }, [gradientSize, mouseX, mouseY])
+
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const rect = e.currentTarget.getBoundingClientRect()
+ mouseX.set(e.clientX - rect.left)
+ mouseY.set(e.clientY - rect.top)
+ },
+ [mouseX, mouseY]
+ )
+
+ useEffect(() => {
+ reset()
+ }, [reset])
+
+ useEffect(() => {
+ const handleGlobalPointerOut = (e: PointerEvent) => {
+ if (!e.relatedTarget) {
+ reset()
+ }
+ }
+
+ const handleVisibility = () => {
+ if (document.visibilityState !== "visible") {
+ reset()
+ }
+ }
+
+ window.addEventListener("pointerout", handleGlobalPointerOut)
+ window.addEventListener("blur", reset)
+ document.addEventListener("visibilitychange", handleVisibility)
+
+ return () => {
+ window.removeEventListener("pointerout", handleGlobalPointerOut)
+ window.removeEventListener("blur", reset)
+ document.removeEventListener("visibilitychange", handleVisibility)
+ }
+ }, [reset])
+
+ return (
+ <div
+ className={cn("group relative rounded-[inherit]", className)}
+ onPointerMove={handlePointerMove}
+ onPointerLeave={reset}
+ onPointerEnter={reset}
+ >
+ <motion.div
+ className="bg-border pointer-events-none absolute inset-0 rounded-[inherit] duration-300 group-hover:opacity-100"
+ style={{
+ background: useMotionTemplate`
+ radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
+ ${gradientFrom},
+ ${gradientTo},
+ var(--border) 100%
+ )
+ `,
+ }}
+ />
+ <div className="bg-background absolute inset-px rounded-[inherit]" />
+ <motion.div
+ className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
+ style={{
+ background: useMotionTemplate`
+ radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)
+ `,
+ opacity: gradientOpacity,
+ }}
+ />
+ <div className="relative">{children}</div>
+ </div>
+ )
+}
diff --git a/jb-ui/src/components/ui/pagination.tsx b/jb-ui/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..1dcfb0c
--- /dev/null
+++ b/jb-ui/src/components/ui/pagination.tsx
@@ -0,0 +1,127 @@
+import * as React from "react"
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ MoreHorizontalIcon,
+} from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants, type Button } from "@/components/ui/button"
+
+function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
+ return (
+ <nav
+ role="navigation"
+ aria-label="pagination"
+ data-slot="pagination"
+ className={cn("mx-auto flex w-full justify-center", className)}
+ {...props}
+ />
+ )
+}
+
+function PaginationContent({
+ className,
+ ...props
+}: React.ComponentProps<"ul">) {
+ return (
+ <ul
+ data-slot="pagination-content"
+ className={cn("flex flex-row items-center gap-1", className)}
+ {...props}
+ />
+ )
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<"li">) {
+ return <li data-slot="pagination-item" {...props} />
+}
+
+type PaginationLinkProps = {
+ isActive?: boolean
+} & Pick<React.ComponentProps<typeof Button>, "size"> &
+ React.ComponentProps<"a">
+
+function PaginationLink({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) {
+ return (
+ <a
+ aria-current={isActive ? "page" : undefined}
+ data-slot="pagination-link"
+ data-active={isActive}
+ className={cn(
+ buttonVariants({
+ variant: isActive ? "outline" : "ghost",
+ size,
+ }),
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function PaginationPrevious({
+ className,
+ ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+ return (
+ <PaginationLink
+ aria-label="Go to previous page"
+ size="default"
+ className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
+ {...props}
+ >
+ <ChevronLeftIcon />
+ <span className="hidden sm:block">Previous</span>
+ </PaginationLink>
+ )
+}
+
+function PaginationNext({
+ className,
+ ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+ return (
+ <PaginationLink
+ aria-label="Go to next page"
+ size="default"
+ className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
+ {...props}
+ >
+ <span className="hidden sm:block">Next</span>
+ <ChevronRightIcon />
+ </PaginationLink>
+ )
+}
+
+function PaginationEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ aria-hidden
+ data-slot="pagination-ellipsis"
+ className={cn("flex size-9 items-center justify-center", className)}
+ {...props}
+ >
+ <MoreHorizontalIcon className="size-4" />
+ <span className="sr-only">More pages</span>
+ </span>
+ )
+}
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+}
diff --git a/jb-ui/src/components/ui/separator.tsx b/jb-ui/src/components/ui/separator.tsx
new file mode 100644
index 0000000..275381c
--- /dev/null
+++ b/jb-ui/src/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+ return (
+ <SeparatorPrimitive.Root
+ data-slot="separator"
+ decorative={decorative}
+ orientation={orientation}
+ className={cn(
+ "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Separator }
diff --git a/jb-ui/src/components/ui/skeleton.tsx b/jb-ui/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..32ea0ef
--- /dev/null
+++ b/jb-ui/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="skeleton"
+ className={cn("bg-accent animate-pulse rounded-md", className)}
+ {...props}
+ />
+ )
+}
+
+export { Skeleton }