{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "github-stars-wheel",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/watermelon-ui/github-stars-wheel.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { StarIcon } from 'lucide-react';\nimport { AnimatePresence,motion, type HTMLMotionProps, isMotionComponent, useInView, type UseInViewOptions, MotionValue, useMotionValue, useSpring, useTransform } from 'motion/react';\nimport { cn } from '@/lib/utils';\n\nfunction percentageBetween(value: number, min: number, max: number): number {\n  return ((value - min) / (max - min)) * 100;\n}\n\ntype GitHubStarsWheelProps = {\n  username?: string;\n  repo?: string;\n  direction?: 'btt' | 'ttb';\n  delay?: number;\n  value?: number;\n  step?: number;\n} & Omit<\n  ScrollingNumberContainerProps,\n  'direction' | 'number' | 'step'\n>;\n\nfunction GitHubStarsWheel({\n  username,\n  repo,\n  direction = 'btt',\n  itemsSize = 35,\n  sideItemsCount = 2,\n  delay = 0,\n  step = 100,\n  value,\n  className,\n  ...props\n}: GitHubStarsWheelProps) {\n  const [stars, setStars] = React.useState(value ?? 0);\n  const [currentStars, setCurrentStars] = React.useState(0);\n  const [isLoading, setIsLoading] = React.useState(true);\n  const roundedStars = React.useMemo(\n    () => Math.round(stars / step) * step,\n    [stars, step],\n  );\n  const isCompleted = React.useMemo(\n    () => currentStars === roundedStars,\n    [currentStars, roundedStars],\n  );\n  const fillPercentage = React.useMemo(\n    () => percentageBetween(currentStars, 0, roundedStars),\n    [currentStars, roundedStars],\n  );\n\n  React.useEffect(() => {\n    if (value !== undefined && username && repo) return;\n\n    const timeout = setTimeout(() => {\n      fetch(`https://api.github.com/repos/${username}/${repo}`)\n        .then((response) => response.json())\n        .then((data) => {\n          if (data && typeof data.stargazers_count === 'number') {\n            setStars(data.stargazers_count);\n          }\n        })\n        .catch(console.error)\n        .finally(() => setIsLoading(false));\n    }, delay);\n\n    return () => clearTimeout(timeout);\n  }, [username, repo, value, delay]);\n\n  return (\n    !isLoading && (\n      <ScrollingNumberContainer\n        key={direction}\n        className={cn('w-28', className)}\n        direction={direction}\n        number={roundedStars}\n        step={step}\n        itemsSize={itemsSize}\n        onNumberChange={setCurrentStars}\n        {...props}\n      >\n        <div\n          className=\"absolute top-0 left-0 w-full bg-gradient-to-t from-transparent to-background z-10\"\n          style={{\n            height: `${itemsSize * sideItemsCount}px`,\n          }}\n        />\n        <div\n          className=\"absolute bottom-0 left-0 w-full bg-gradient-to-b from-transparent to-background z-10\"\n          style={{\n            height: `${itemsSize * sideItemsCount}px`,\n          }}\n        />\n        <ScrollingNumber delay={delay}>\n          <ScrollingNumberItems className=\"flex items-center justify-start pl-8\" />\n        </ScrollingNumber>\n        <ScrollingNumberHighlight className=\"bg-accent/40 border rounded-md size-full flex items-center pl-2\">\n          <Particles animate={isCompleted}>\n            <StarIcon\n              aria-hidden=\"true\"\n              className=\"fill-neutral-300 stroke-neutral-300 dark:fill-neutral-700 dark:stroke-neutral-700 size-4\"\n            />\n            <StarIcon\n              aria-hidden=\"true\"\n              className=\"absolute top-0 left-0 size-4 fill-yellow-500 stroke-yellow-500\"\n              style={{\n                clipPath: `inset(${100 - (isCompleted ? fillPercentage : fillPercentage - 10)}% 0 0 0)`,\n              }}\n            />\n            <ParticlesEffect\n              delay={0.5}\n              className=\"size-1 rounded-full bg-yellow-500\"\n            />\n          </Particles>\n        </ScrollingNumberHighlight>\n      </ScrollingNumberContainer>\n    )\n  );\n}\n\nexport { GitHubStarsWheel, type GitHubStarsWheelProps };\n\n\ntype Side = 'top' | 'bottom' | 'left' | 'right';\ntype Align = 'start' | 'center' | 'end';\n\ntype ParticlesContextType = {\n  animate: boolean;\n  isInView: boolean;\n};\n\nconst [ParticlesProvider, useParticles] =\n  getStrictContext<ParticlesContextType>('ParticlesContext');\n\ntype ParticlesProps = WithAsChild<\n  Omit<HTMLMotionProps<'div'>, 'children'> & {\n    animate?: boolean;\n    children: React.ReactNode;\n  } & UseIsInViewOptions\n>;\n\nfunction Particles({\n  ref,\n  animate = true,\n  asChild = false,\n  inView = false,\n  inViewMargin = '0px',\n  inViewOnce = true,\n  children,\n  style,\n  ...props\n}: ParticlesProps) {\n  const { ref: localRef, isInView } = useIsInView(\n    ref as React.Ref<HTMLDivElement>,\n    { inView, inViewOnce, inViewMargin },\n  );\n\n  const Component = asChild ? Slot : motion.div;\n\n  return (\n    <ParticlesProvider value={{ animate, isInView }}>\n      <Component\n        ref={localRef}\n        style={{ position: 'relative', ...style }}\n        {...props}\n      >\n        {children}\n      </Component>\n    </ParticlesProvider>\n  );\n}\n\ntype ParticlesEffectProps = Omit<HTMLMotionProps<'div'>, 'children'> & {\n  side?: Side;\n  align?: Align;\n  count?: number;\n  radius?: number;\n  spread?: number;\n  duration?: number;\n  holdDelay?: number;\n  sideOffset?: number;\n  alignOffset?: number;\n  delay?: number;\n};\n\nfunction ParticlesEffect({\n  side = 'top',\n  align = 'center',\n  count = 6,\n  radius = 30,\n  spread = 360,\n  duration = 0.8,\n  holdDelay = 0.05,\n  sideOffset = 0,\n  alignOffset = 0,\n  delay = 0,\n  transition,\n  style,\n  ...props\n}: ParticlesEffectProps) {\n  const { animate, isInView } = useParticles();\n\n  const isVertical = side === 'top' || side === 'bottom';\n  const alignPct = align === 'start' ? '0%' : align === 'end' ? '100%' : '50%';\n\n  const top = isVertical\n    ? side === 'top'\n      ? `calc(0% - ${sideOffset}px)`\n      : `calc(100% + ${sideOffset}px)`\n    : `calc(${alignPct} + ${alignOffset}px)`;\n\n  const left = isVertical\n    ? `calc(${alignPct} + ${alignOffset}px)`\n    : side === 'left'\n      ? `calc(0% - ${sideOffset}px)`\n      : `calc(100% + ${sideOffset}px)`;\n\n  const containerStyle: React.CSSProperties = {\n    position: 'absolute',\n    top,\n    left,\n    transform: 'translate(-50%, -50%)',\n  };\n\n  const angleStep = (spread * (Math.PI / 180)) / Math.max(1, count - 1);\n\n  return (\n    <AnimatePresence>\n      {animate &&\n        isInView &&\n        [...Array(count)].map((_, i) => {\n          const angle = i * angleStep;\n          const x = Math.cos(angle) * radius;\n          const y = Math.sin(angle) * radius;\n\n          return (\n            <motion.div\n              key={i}\n              style={{ ...containerStyle, ...style }}\n              initial={{ scale: 0, opacity: 0 }}\n              animate={{\n                x: `${x}px`,\n                y: `${y}px`,\n                scale: [0, 1, 0],\n                opacity: [0, 1, 0],\n              }}\n              transition={{\n                duration,\n                delay: delay + i * holdDelay,\n                ease: 'easeOut',\n                ...transition,\n              }}\n              {...props}\n            />\n          );\n        })}\n    </AnimatePresence>\n  );\n}\n\nexport {\n  Particles,\n  ParticlesEffect,\n  type ParticlesProps,\n  type ParticlesEffectProps,\n};\n\n\n\ntype AnyProps = Record<string, unknown>;\n\ntype DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<\n  HTMLMotionProps<keyof HTMLElementTagNameMap>,\n  'ref'\n> & { ref?: React.Ref<T> };\n\ntype WithAsChild<Base extends object> =\n  | (Base & { asChild: true; children: React.ReactElement })\n  | (Base & { asChild?: false | undefined });\n\ntype SlotProps<T extends HTMLElement = HTMLElement> = {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  children?: any;\n} & DOMMotionProps<T>;\n\nfunction mergeRefs<T>(\n  ...refs: (React.Ref<T> | undefined)[]\n): React.RefCallback<T> {\n  return (node) => {\n    refs.forEach((ref) => {\n      if (!ref) return;\n      if (typeof ref === 'function') {\n        ref(node);\n      } else {\n        (ref as React.RefObject<T | null>).current = node;\n      }\n    });\n  };\n}\n\nfunction mergeProps<T extends HTMLElement>(\n  childProps: AnyProps,\n  slotProps: DOMMotionProps<T>,\n): AnyProps {\n  const merged: AnyProps = { ...childProps, ...slotProps };\n\n  if (childProps.className || slotProps.className) {\n    merged.className = cn(\n      childProps.className as string,\n      slotProps.className as string,\n    );\n  }\n\n  if (childProps.style || slotProps.style) {\n    merged.style = {\n      ...(childProps.style as React.CSSProperties),\n      ...(slotProps.style as React.CSSProperties),\n    };\n  }\n\n  return merged;\n}\n\nfunction Slot<T extends HTMLElement = HTMLElement>({\n  children,\n  ref,\n  ...props\n}: SlotProps<T>) {\n  const isAlreadyMotion =\n    typeof children.type === 'object' &&\n    children.type !== null &&\n    isMotionComponent(children.type);\n\n  const Base = React.useMemo(\n    () =>\n      isAlreadyMotion\n        ? (children.type as React.ElementType)\n        : motion.create(children.type as React.ElementType),\n    [isAlreadyMotion, children.type],\n  );\n\n  if (!React.isValidElement(children)) return null;\n\n  const { ref: childRef, ...childProps } = children.props as AnyProps;\n\n  const mergedProps = mergeProps(childProps, props);\n\n  return (\n    // eslint-disable-next-line react-hooks/static-components\n    <Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />\n  );\n}\n\nexport {\n  Slot,\n  type SlotProps,\n  type WithAsChild,\n  type DOMMotionProps,\n  type AnyProps,\n};\n\n\n\ninterface UseIsInViewOptions {\n  inView?: boolean;\n  inViewOnce?: boolean;\n  inViewMargin?: UseInViewOptions['margin'];\n}\n\nfunction useIsInView<T extends HTMLElement = HTMLElement>(\n  ref: React.Ref<T>,\n  options: UseIsInViewOptions = {},\n) {\n  const { inView, inViewOnce = false, inViewMargin = '0px' } = options;\n  const localRef = React.useRef<T>(null);\n  React.useImperativeHandle(ref, () => localRef.current as T);\n  const inViewResult = useInView(localRef, {\n    once: inViewOnce,\n    margin: inViewMargin,\n  });\n  const isInView = !inView || inViewResult;\n  return { ref: localRef, isInView };\n}\n\nexport { useIsInView, type UseIsInViewOptions };\n\n\nfunction getStrictContext<T>(\n  name?: string,\n): readonly [\n  ({\n    value,\n    children,\n  }: {\n    value: T;\n    children?: React.ReactNode;\n  }) => React.JSX.Element,\n  () => T,\n] {\n  const Context = React.createContext<T | undefined>(undefined);\n\n  const Provider = ({\n    value,\n    children,\n  }: {\n    value: T;\n    children?: React.ReactNode;\n  }) => <Context.Provider value={value}>{children}</Context.Provider>;\n\n  const useSafeContext = () => {\n    const ctx = React.useContext(Context);\n    if (ctx === undefined) {\n      throw new Error(`useContext must be used within ${name ?? 'a Provider'}`);\n    }\n    return ctx;\n  };\n\n  return [Provider, useSafeContext] as const;\n}\n\nexport { getStrictContext };\n\n\nconst formatter = new Intl.NumberFormat('en-US');\n\nfunction generateRange(\n  max: number,\n  step: number,\n  sideItemsCount: number,\n): number[] {\n  const result: number[] = [];\n  const end = max + sideItemsCount * step;\n  for (let value = end; value >= 0; value -= step) {\n    result.push(value);\n  }\n  return result;\n}\n\ntype ScrollingNumberDirection = 'ltr' | 'rtl' | 'ttb' | 'btt';\n\ntype ScrollingNumberContextType = {\n  number: number;\n  step: number;\n  itemsSize: number;\n  sideItemsCount: number;\n  displayedItemsCount: number;\n  isInView: boolean;\n  direction: ScrollingNumberDirection;\n  isVertical: boolean;\n  range: number[];\n  onNumberChange?: (value: number) => void;\n};\n\nconst [ScrollingNumberProvider, useScrollingNumber] =\n  getStrictContext<ScrollingNumberContextType>('ScrollingNumberContext');\n\ntype ScrollingNumberContainerProps = React.ComponentProps<'div'> & {\n  number: number;\n  step: number;\n  itemsSize?: number;\n  sideItemsCount?: number;\n  direction?: ScrollingNumberDirection;\n  onNumberChange?: (value: number) => void;\n} & UseIsInViewOptions;\n\nfunction ScrollingNumberContainer({\n  ref,\n  number,\n  step,\n  itemsSize = 30,\n  sideItemsCount = 2,\n  direction = 'btt',\n  inView = false,\n  inViewMargin = '0px',\n  inViewOnce = true,\n  onNumberChange,\n  style,\n  ...props\n}: ScrollingNumberContainerProps) {\n  const { ref: localRef, isInView } = useIsInView(\n    ref as React.Ref<HTMLDivElement>,\n    {\n      inView,\n      inViewOnce,\n      inViewMargin,\n    },\n  );\n\n  const displayedItemsCount = React.useMemo(\n    () => 1 + sideItemsCount * 2,\n    [sideItemsCount],\n  );\n  const isVertical = React.useMemo(\n    () => direction === 'btt' || direction === 'ttb',\n    [direction],\n  );\n  const range = React.useMemo(\n    () => generateRange(number, step, sideItemsCount),\n    [number, step, sideItemsCount],\n  );\n\n  return (\n    <ScrollingNumberProvider\n      value={{\n        number,\n        step,\n        itemsSize,\n        sideItemsCount,\n        displayedItemsCount,\n        isInView,\n        direction,\n        isVertical,\n        range,\n        onNumberChange,\n      }}\n    >\n      <div\n        ref={localRef}\n        data-slot=\"scrolling-number-container\"\n        data-direction={direction}\n        style={{\n          position: 'relative',\n          overflow: 'hidden',\n          height: isVertical ? itemsSize * displayedItemsCount : undefined,\n          width: !isVertical ? itemsSize * displayedItemsCount : undefined,\n          ...style,\n        }}\n        {...props}\n      />\n    </ScrollingNumberProvider>\n  );\n}\n\ntype ScrollingNumberHighlightProps = React.ComponentProps<'div'>;\n\nfunction ScrollingNumberHighlight({\n  style,\n  ...props\n}: ScrollingNumberHighlightProps) {\n  const { itemsSize, isVertical, direction } = useScrollingNumber();\n  return (\n    <div\n      data-slot=\"scrolling-number-highlight\"\n      data-direction={direction}\n      style={{\n        position: 'absolute',\n        height: isVertical ? itemsSize : undefined,\n        width: !isVertical ? itemsSize : undefined,\n        left: !isVertical ? '50%' : undefined,\n        top: isVertical ? '50%' : undefined,\n        transform: !isVertical ? 'translateX(-50%)' : 'translateY(-50%)',\n        zIndex: 0,\n        ...style,\n      }}\n      {...props}\n    />\n  );\n}\n\ntype ScrollingNumberProps = HTMLMotionProps<'div'> & {\n  delay?: number;\n  onCompleted?: () => void;\n};\n\nfunction ScrollingNumber({\n  transition = { stiffness: 90, damping: 30 },\n  delay = 0,\n  onCompleted,\n  style,\n  ...props\n}: ScrollingNumberProps) {\n  const {\n    itemsSize,\n    sideItemsCount,\n    displayedItemsCount,\n    isInView,\n    direction,\n    isVertical,\n    range,\n    step,\n    number,\n    onNumberChange,\n  } = useScrollingNumber();\n\n  const motionKey: 'x' | 'y' = isVertical ? 'y' : 'x';\n  const initialOffset = itemsSize * sideItemsCount;\n  const travel = itemsSize * (range.length - displayedItemsCount);\n\n  let initialPosition: number;\n  let finalPosition: number;\n\n  switch (direction) {\n    case 'btt':\n      initialPosition = -initialOffset;\n      finalPosition = travel;\n      break;\n    case 'ttb':\n      initialPosition = initialOffset;\n      finalPosition = -travel;\n      break;\n    case 'rtl':\n      initialPosition = -initialOffset;\n      finalPosition = travel;\n      break;\n    case 'ltr':\n      initialPosition = initialOffset;\n      finalPosition = -travel;\n      break;\n    default:\n      initialPosition = -initialOffset;\n      finalPosition = travel;\n  }\n\n  const posMotion: MotionValue<number> = useMotionValue(initialPosition);\n  const posSpring = useSpring(posMotion, transition);\n\n  React.useEffect(() => {\n    if (!isInView) return;\n    const timer = setTimeout(() => {\n      posMotion.set(finalPosition);\n    }, delay);\n    return () => clearTimeout(timer);\n  }, [isInView, finalPosition, posMotion, delay]);\n\n  const currentIndex = useTransform(\n    posSpring,\n    (p) => Math.abs(p) / itemsSize + sideItemsCount,\n  );\n  const currentValue = useTransform(currentIndex, (idx) => idx * step);\n  const snappedValue = useTransform(\n    currentIndex,\n    (idx) => Math.round(idx) * step,\n  );\n\n  const completedTransform = useTransform(\n    currentValue,\n    (val) => val >= number * 0.99,\n  );\n\n  React.useEffect(() => {\n    const unsubscribe = completedTransform.on('change', (latest) => {\n      if (latest) onCompleted?.();\n    });\n    return unsubscribe;\n  }, [completedTransform, onCompleted]);\n\n  React.useEffect(() => {\n    const unsub = snappedValue.on('change', (val) => {\n      const bounded = val < 0 ? 0 : val > number ? number : val;\n      onNumberChange?.(bounded);\n    });\n    return unsub;\n  }, [snappedValue, onNumberChange, number]);\n\n  const directionMap: Record<\n    ScrollingNumberDirection,\n    React.CSSProperties['flexDirection']\n  > = {\n    btt: 'column',\n    ttb: 'column-reverse',\n    rtl: 'row',\n    ltr: 'row-reverse',\n  };\n\n  return (\n    <motion.div\n      data-slot=\"scrolling-number\"\n      style={{\n        position: 'absolute',\n        top: direction === 'ttb' ? 0 : undefined,\n        bottom: direction === 'btt' ? 0 : undefined,\n        left: direction === 'ltr' ? 0 : undefined,\n        right: direction === 'rtl' ? 0 : undefined,\n        width: isVertical ? '100%' : undefined,\n        height: !isVertical ? '100%' : undefined,\n        display: 'flex',\n        zIndex: 1,\n        flexDirection: directionMap[direction],\n        [motionKey]: posSpring,\n        ...style,\n      }}\n      {...props}\n    />\n  );\n}\n\ntype ScrollingNumberItemsProps = Omit<React.ComponentProps<'div'>, 'children'>;\n\nfunction ScrollingNumberItems({ style, ...props }: ScrollingNumberItemsProps) {\n  const { range, direction, itemsSize, isVertical } = useScrollingNumber();\n  return range.map((value) => (\n    <div\n      key={value}\n      data-slot=\"scrolling-number-item\"\n      data-value={value}\n      data-direction={direction}\n      style={{\n        height: isVertical ? itemsSize : undefined,\n        width: !isVertical ? itemsSize : undefined,\n        ...style,\n      }}\n      {...props}\n    >\n      {formatter.format(value)}\n    </div>\n  ));\n}\n\nexport {\n  ScrollingNumberContainer,\n  ScrollingNumber,\n  ScrollingNumberHighlight,\n  ScrollingNumberItems,\n  useScrollingNumber,\n  type ScrollingNumberContainerProps,\n  type ScrollingNumberProps,\n  type ScrollingNumberHighlightProps,\n  type ScrollingNumberItemsProps,\n  type ScrollingNumberDirection,\n  type ScrollingNumberContextType,\n};\n",
      "type": "registry:ui",
      "target": "components/ui/github-stars-wheel.tsx"
    }
  ],
  "type": "registry:ui"
}
