From e9fd39d3547a65eedd253431e188210c5db6b786 Mon Sep 17 00:00:00 2001 From: Bram Suurd <78373894+BramSuurdje@users.noreply.github.com> Date: Sun, 7 Sep 2025 22:58:18 +0200 Subject: [PATCH] feat: enhance github stars button to be better looking and more compact to make mobile compatibility easier in the future --- frontend/components.json | 3 + frontend/package-lock.json | 95 ++++- frontend/package.json | 4 +- .../components/buttons/github-stars.tsx | 101 ++++++ .../primitives/animate/github-stars.tsx | 206 +++++++++++ .../animate-ui/primitives/animate/slot.tsx | 101 ++++++ .../animate-ui/primitives/buttons/button.tsx | 36 ++ .../primitives/effects/particles.tsx | 160 +++++++++ .../primitives/texts/sliding-number.tsx | 338 ++++++++++++++++++ frontend/src/components/navbar.tsx | 23 +- frontend/src/hooks/use-is-in-view.tsx | 27 ++ frontend/src/lib/get-strict-context.tsx | 36 ++ 12 files changed, 1106 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/animate-ui/components/buttons/github-stars.tsx create mode 100644 frontend/src/components/animate-ui/primitives/animate/github-stars.tsx create mode 100644 frontend/src/components/animate-ui/primitives/animate/slot.tsx create mode 100644 frontend/src/components/animate-ui/primitives/buttons/button.tsx create mode 100644 frontend/src/components/animate-ui/primitives/effects/particles.tsx create mode 100644 frontend/src/components/animate-ui/primitives/texts/sliding-number.tsx create mode 100644 frontend/src/hooks/use-is-in-view.tsx create mode 100644 frontend/src/lib/get-strict-context.tsx diff --git a/frontend/components.json b/frontend/components.json index 380285be3..eb538c1c0 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -13,5 +13,8 @@ "aliases": { "components": "@/components", "utils": "@/lib/utils" + }, + "registries": { + "@animate-ui": "https://animate-ui.com/r/{name}.json" } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6a1a2b309..4a8ddbdd3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,8 +31,9 @@ "date-fns": "^4.1.0", "framer-motion": "^11.18.2", "fuse.js": "^7.1.0", - "lucide-react": "^0.453.0", + "lucide-react": "^0.542.0", "mini-svg-data-uri": "^1.4.4", + "motion": "^12.23.12", "next": "15.5.2", "next-themes": "^0.4.4", "nuqs": "^2.4.1", @@ -46,6 +47,7 @@ "react-dom": "19.0.0", "react-icons": "^5.5.0", "react-simple-typewriter": "^5.0.1", + "react-use-measure": "^2.1.7", "sharp": "^0.33.5", "simple-icons": "^13.21.0", "sonner": "^1.7.4", @@ -9293,12 +9295,12 @@ } }, "node_modules/lucide-react": { - "version": "0.453.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.453.0.tgz", - "integrity": "sha512-kL+RGZCcJi9BvJtzg2kshO192Ddy9hv3ij+cPrVPWSRzgCWCVazoQJxOjAwgK53NomL07HB7GPHW120FimjNhQ==", + "version": "0.542.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", + "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/magic-string": { @@ -10339,6 +10341,32 @@ "pathe": "^2.0.1" } }, + "node_modules/motion": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.12.tgz", + "integrity": "sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.12", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -10354,6 +10382,48 @@ "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", "license": "MIT" }, + "node_modules/motion/node_modules/framer-motion": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz", + "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.12", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion/node_modules/motion-dom": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz", + "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion/node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11990,6 +12060,21 @@ "react": ">= 0.14.0" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2b9965b5f..ae07382e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,8 +38,9 @@ "date-fns": "^4.1.0", "framer-motion": "^11.18.2", "fuse.js": "^7.1.0", - "lucide-react": "^0.453.0", + "lucide-react": "^0.542.0", "mini-svg-data-uri": "^1.4.4", + "motion": "^12.23.12", "next": "15.5.2", "next-themes": "^0.4.4", "nuqs": "^2.4.1", @@ -53,6 +54,7 @@ "react-dom": "19.0.0", "react-icons": "^5.5.0", "react-simple-typewriter": "^5.0.1", + "react-use-measure": "^2.1.7", "sharp": "^0.33.5", "simple-icons": "^13.21.0", "sonner": "^1.7.4", diff --git a/frontend/src/components/animate-ui/components/buttons/github-stars.tsx b/frontend/src/components/animate-ui/components/buttons/github-stars.tsx new file mode 100644 index 000000000..c32574c4b --- /dev/null +++ b/frontend/src/components/animate-ui/components/buttons/github-stars.tsx @@ -0,0 +1,101 @@ +import type { VariantProps } from "class-variance-authority"; + +import { cva } from "class-variance-authority"; +import { StarIcon } from "lucide-react"; + +import type { ButtonProps as ButtonPrimitiveProps } from "@/components/animate-ui/primitives/buttons/button"; +import type { GithubStarsProps } from "@/components/animate-ui/primitives/animate/github-stars"; + +import { + GithubStars, + GithubStarsIcon, + GithubStarsLogo, + GithubStarsNumber, + GithubStarsParticles, +} from "@/components/animate-ui/primitives/animate/github-stars"; +import { Button as ButtonPrimitive } from "@/components/animate-ui/primitives/buttons/button"; +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-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] 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 shadow-xs hover:bg-primary/90", + accent: "bg-accent text-accent-foreground shadow-xs hover:bg-accent/90", + 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", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-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", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +const buttonStarVariants = cva("", { + variants: { + variant: { + default: "fill-neutral-700 stroke-neutral-700 dark:fill-neutral-300 dark:stroke-neutral-300", + accent: "fill-neutral-300 stroke-neutral-300 dark:fill-neutral-700 dark:stroke-neutral-700", + outline: "fill-neutral-300 stroke-neutral-300 dark:fill-neutral-700 dark:stroke-neutral-700", + ghost: "fill-neutral-300 stroke-neutral-300 dark:fill-neutral-700 dark:stroke-neutral-700", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +type GitHubStarsButtonProps = Omit + & VariantProps; + +function GitHubStarsButton({ + className, + username, + repo, + value, + delay, + inView, + inViewMargin, + inViewOnce, + variant, + size, + ...props +}: GitHubStarsButtonProps) { + return ( + + + + + + + + + + ); +} + +export { GitHubStarsButton, type GitHubStarsButtonProps }; diff --git a/frontend/src/components/animate-ui/primitives/animate/github-stars.tsx b/frontend/src/components/animate-ui/primitives/animate/github-stars.tsx new file mode 100644 index 000000000..072d814f9 --- /dev/null +++ b/frontend/src/components/animate-ui/primitives/animate/github-stars.tsx @@ -0,0 +1,206 @@ +"use client"; + +import type { HTMLMotionProps } from "motion/react"; + +import { motion } from "motion/react"; +import * as React from "react"; + +import type { SlidingNumberProps } from "@/components/animate-ui/primitives/texts/sliding-number"; +import type { ParticlesEffectProps } from "@/components/animate-ui/primitives/effects/particles"; +import type { WithAsChild } from "@/components/animate-ui/primitives/animate/slot"; +import type { UseIsInViewOptions } from "@/hooks/use-is-in-view"; + +import { Particles, ParticlesEffect } from "@/components/animate-ui/primitives/effects/particles"; +import { SlidingNumber } from "@/components/animate-ui/primitives/texts/sliding-number"; +import { Slot } from "@/components/animate-ui/primitives/animate/slot"; +import { getStrictContext } from "@/lib/get-strict-context"; +import { useIsInView } from "@/hooks/use-is-in-view"; +import { cn } from "@/lib/utils"; + +type GithubStarsContextType = { + stars: number; + setStars: (stars: number) => void; + currentStars: number; + setCurrentStars: (stars: number) => void; + isCompleted: boolean; + isLoading: boolean; +}; + +const [GithubStarsProvider, useGithubStars] = getStrictContext("GithubStarsContext"); + +type GithubStarsProps = WithAsChild< + { + children: React.ReactNode; + username?: string; + repo?: string; + value?: number; + delay?: number; + } & UseIsInViewOptions + & HTMLMotionProps<"div"> +>; + +function GithubStars({ + ref, + children, + username, + repo, + value, + delay = 0, + inView = false, + inViewMargin = "0px", + inViewOnce = true, + asChild = false, + ...props +}: GithubStarsProps) { + const { ref: localRef, isInView } = useIsInView(ref as React.Ref, { + inView, + inViewOnce, + inViewMargin, + }); + + const [stars, setStars] = React.useState(value ?? 0); + const [currentStars, setCurrentStars] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(true); + const isCompleted = React.useMemo(() => currentStars === stars, [currentStars, stars]); + + const Component = asChild ? Slot : motion.div; + + React.useEffect(() => { + if (value !== undefined && username && repo) + return; + if (!isInView) { + setStars(0); + setIsLoading(true); + return; + } + + const timeout = setTimeout(() => { + fetch(`https://api.github.com/repos/${username}/${repo}`) + .then(response => response.json()) + .then((data) => { + if (data && typeof data.stargazers_count === "number") { + setStars(data.stargazers_count); + } + }) + .catch(console.error) + .finally(() => setIsLoading(false)); + }, delay); + + return () => clearTimeout(timeout); + }, [username, repo, value, isInView, delay]); + + return ( + + {!isLoading && ( + + {children} + + )} + + ); +} + +type GithubStarsNumberProps = Omit; + +function GithubStarsNumber({ padStart = true, ...props }: GithubStarsNumberProps) { + const { stars, setCurrentStars } = useGithubStars(); + + return ( + + ); +} + +type GithubStarsIconProps = { + icon: React.ReactElement; + color?: string; + activeClassName?: string; +} & React.ComponentProps; + +function GithubStarsIcon({ + icon: Icon, + color = "currentColor", + activeClassName, + className, + ...props +}: GithubStarsIconProps) { + const { stars, currentStars, isCompleted } = useGithubStars(); + const fillPercentage = (currentStars / stars) * 100; + + return ( +
+
+ ); +} + +type GithubStarsParticlesProps = ParticlesEffectProps & { + children: React.ReactElement; + size?: number; +}; + +function GithubStarsParticles({ children, size = 4, style, ...props }: GithubStarsParticlesProps) { + const { isCompleted } = useGithubStars(); + + return ( + + {children} + + + ); +} + +type GithubStarsLogoProps = React.SVGProps; + +function GithubStarsLogo(props: GithubStarsLogoProps) { + return ( + + + + ); +} + +export { + GithubStars, + type GithubStarsContextType, + GithubStarsIcon, + type GithubStarsIconProps, + GithubStarsLogo, + type GithubStarsLogoProps, + GithubStarsNumber, + type GithubStarsNumberProps, + GithubStarsParticles, + type GithubStarsParticlesProps, + type GithubStarsProps, + useGithubStars, +}; diff --git a/frontend/src/components/animate-ui/primitives/animate/slot.tsx b/frontend/src/components/animate-ui/primitives/animate/slot.tsx new file mode 100644 index 000000000..a6110b956 --- /dev/null +++ b/frontend/src/components/animate-ui/primitives/animate/slot.tsx @@ -0,0 +1,101 @@ +"use client"; + +import type { HTMLMotionProps } from "motion/react"; + +import { isMotionComponent, motion } from "motion/react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +type AnyProps = Record; + +type DOMMotionProps = Omit< + HTMLMotionProps, + "ref" +> & { ref?: React.Ref }; + +type WithAsChild + = | (Base & { asChild: true; children: React.ReactElement }) + | (Base & { asChild?: false | undefined }); + +type SlotProps = { + children?: any; +} & DOMMotionProps; + +function mergeRefs( + ...refs: (React.Ref | undefined)[] +): React.RefCallback { + return (node) => { + refs.forEach((ref) => { + if (!ref) + return; + if (typeof ref === "function") { + ref(node); + } + else { + (ref as React.RefObject).current = node; + } + }); + }; +} + +function mergeProps( + childProps: AnyProps, + slotProps: DOMMotionProps, +): AnyProps { + const merged: AnyProps = { ...childProps, ...slotProps }; + + if (childProps.className || slotProps.className) { + merged.className = cn( + childProps.className as string, + slotProps.className as string, + ); + } + + if (childProps.style || slotProps.style) { + merged.style = { + ...(childProps.style as React.CSSProperties), + ...(slotProps.style as React.CSSProperties), + }; + } + + return merged; +} + +function Slot({ + children, + ref, + ...props +}: SlotProps) { + const isAlreadyMotion + = typeof children.type === "object" + && children.type !== null + && isMotionComponent(children.type); + + const Base = React.useMemo( + () => + isAlreadyMotion + ? (children.type as React.ElementType) + : motion.create(children.type as React.ElementType), + [isAlreadyMotion, children.type], + ); + + if (!React.isValidElement(children)) + return null; + + const { ref: childRef, ...childProps } = children.props as AnyProps; + + const mergedProps = mergeProps(childProps, props); + + return ( + , ref)} /> + ); +} + +export { + type AnyProps, + type DOMMotionProps, + Slot, + type SlotProps, + type WithAsChild, +}; diff --git a/frontend/src/components/animate-ui/primitives/buttons/button.tsx b/frontend/src/components/animate-ui/primitives/buttons/button.tsx new file mode 100644 index 000000000..596173651 --- /dev/null +++ b/frontend/src/components/animate-ui/primitives/buttons/button.tsx @@ -0,0 +1,36 @@ +"use client"; + +import type { HTMLMotionProps } from "motion/react"; + +import { motion } from "motion/react"; +import * as React from "react"; + +import type { WithAsChild } from "@/components/animate-ui/primitives/animate/slot"; + +import { Slot } from "@/components/animate-ui/primitives/animate/slot"; + +type ButtonProps = WithAsChild< + HTMLMotionProps<"button"> & { + hoverScale?: number; + tapScale?: number; + } +>; + +function Button({ + hoverScale = 1.05, + tapScale = 0.95, + asChild = false, + ...props +}: ButtonProps) { + const Component = asChild ? Slot : motion.button; + + return ( + + ); +} + +export { Button, type ButtonProps }; diff --git a/frontend/src/components/animate-ui/primitives/effects/particles.tsx b/frontend/src/components/animate-ui/primitives/effects/particles.tsx new file mode 100644 index 000000000..397043cbb --- /dev/null +++ b/frontend/src/components/animate-ui/primitives/effects/particles.tsx @@ -0,0 +1,160 @@ +"use client"; + +import type { HTMLMotionProps } from "motion/react"; + +import { AnimatePresence, motion } from "motion/react"; +import * as React from "react"; + +import type { WithAsChild } from "@/components/animate-ui/primitives/animate/slot"; +import type { UseIsInViewOptions } from "@/hooks/use-is-in-view"; + +import { Slot } from "@/components/animate-ui/primitives/animate/slot"; +import { getStrictContext } from "@/lib/get-strict-context"; +import { + useIsInView, + +} from "@/hooks/use-is-in-view"; + +type Side = "top" | "bottom" | "left" | "right"; +type Align = "start" | "center" | "end"; + +type ParticlesContextType = { + animate: boolean; + isInView: boolean; +}; + +const [ParticlesProvider, useParticles] + = getStrictContext("ParticlesContext"); + +type ParticlesProps = WithAsChild< + Omit, "children"> & { + animate?: boolean; + children: React.ReactNode; + } & UseIsInViewOptions +>; + +function Particles({ + ref, + animate = true, + asChild = false, + inView = false, + inViewMargin = "0px", + inViewOnce = true, + children, + style, + ...props +}: ParticlesProps) { + const { ref: localRef, isInView } = useIsInView( + ref as React.Ref, + { inView, inViewOnce, inViewMargin }, + ); + + const Component = asChild ? Slot : motion.div; + + return ( + + + {children} + + + ); +} + +type ParticlesEffectProps = Omit, "children"> & { + side?: Side; + align?: Align; + count?: number; + radius?: number; + spread?: number; + duration?: number; + holdDelay?: number; + sideOffset?: number; + alignOffset?: number; + delay?: number; +}; + +function ParticlesEffect({ + side = "top", + align = "center", + count = 6, + radius = 30, + spread = 360, + duration = 0.8, + holdDelay = 0.05, + sideOffset = 0, + alignOffset = 0, + delay = 0, + transition, + style, + ...props +}: ParticlesEffectProps) { + const { animate, isInView } = useParticles(); + + const isVertical = side === "top" || side === "bottom"; + const alignPct = align === "start" ? "0%" : align === "end" ? "100%" : "50%"; + + const top = isVertical + ? side === "top" + ? `calc(0% - ${sideOffset}px)` + : `calc(100% + ${sideOffset}px)` + : `calc(${alignPct} + ${alignOffset}px)`; + + const left = isVertical + ? `calc(${alignPct} + ${alignOffset}px)` + : side === "left" + ? `calc(0% - ${sideOffset}px)` + : `calc(100% + ${sideOffset}px)`; + + const containerStyle: React.CSSProperties = { + position: "absolute", + top, + left, + transform: "translate(-50%, -50%)", + }; + + const angleStep = (spread * (Math.PI / 180)) / Math.max(1, count - 1); + + return ( + + {animate + && isInView + && Array.from({ length: count }).map((_, i) => { + const angle = i * angleStep; + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + + return ( + + ); + })} + + ); +} + +export { + Particles, + ParticlesEffect, + type ParticlesEffectProps, + type ParticlesProps, +}; diff --git a/frontend/src/components/animate-ui/primitives/texts/sliding-number.tsx b/frontend/src/components/animate-ui/primitives/texts/sliding-number.tsx new file mode 100644 index 000000000..300f91fb7 --- /dev/null +++ b/frontend/src/components/animate-ui/primitives/texts/sliding-number.tsx @@ -0,0 +1,338 @@ +"use client"; + +import type { MotionValue, SpringOptions } from "motion/react"; + +import { + motion, + + useMotionValue, + useSpring, + useTransform, +} from "motion/react"; +import useMeasure from "react-use-measure"; +import * as React from "react"; + +import type { UseIsInViewOptions } from "@/hooks/use-is-in-view"; + +import { + useIsInView, + +} from "@/hooks/use-is-in-view"; + +type SlidingNumberRollerProps = { + prevValue: number; + value: number; + place: number; + transition: SpringOptions; + delay?: number; +}; + +function SlidingNumberRoller({ + prevValue, + value, + place, + transition, + delay = 0, +}: SlidingNumberRollerProps) { + const startNumber = Math.floor(prevValue / place) % 10; + const targetNumber = Math.floor(value / place) % 10; + const animatedValue = useSpring(startNumber, transition); + + React.useEffect(() => { + const timeoutId = setTimeout(() => { + animatedValue.set(targetNumber); + }, delay); + return () => clearTimeout(timeoutId); + }, [targetNumber, animatedValue, delay]); + + const [measureRef, { height }] = useMeasure(); + + return ( + + 0 + {Array.from({ length: 10 }, (_, i) => ( + + ))} + + ); +} + +type SlidingNumberDisplayProps = { + motionValue: MotionValue; + number: number; + height: number; + transition: SpringOptions; +}; + +function SlidingNumberDisplay({ + motionValue, + number, + height, + transition, +}: SlidingNumberDisplayProps) { + const y = useTransform(motionValue, (latest) => { + if (!height) + return 0; + const currentNumber = latest % 10; + const offset = (10 + number - currentNumber) % 10; + let translateY = offset * height; + if (offset > 5) + translateY -= 10 * height; + return translateY; + }); + + if (!height) { + return ( + + {number} + + ); + } + + return ( + + {number} + + ); +} + +type SlidingNumberProps = Omit, "children"> & { + number: number; + fromNumber?: number; + onNumberChange?: (number: number) => void; + padStart?: boolean; + decimalSeparator?: string; + decimalPlaces?: number; + thousandSeparator?: string; + transition?: SpringOptions; + delay?: number; +} & UseIsInViewOptions; + +function SlidingNumber({ + ref, + number, + fromNumber, + onNumberChange, + inView = false, + inViewMargin = "0px", + inViewOnce = true, + padStart = false, + decimalSeparator = ".", + decimalPlaces = 0, + thousandSeparator, + transition = { stiffness: 200, damping: 20, mass: 0.4 }, + delay = 0, + ...props +}: SlidingNumberProps) { + const { ref: localRef, isInView } = useIsInView( + ref as React.Ref, + { + inView, + inViewOnce, + inViewMargin, + }, + ); + + const prevNumberRef = React.useRef(0); + + const hasAnimated = fromNumber !== undefined; + const motionVal = useMotionValue(fromNumber ?? 0); + const springVal = useSpring(motionVal, { stiffness: 90, damping: 50 }); + + React.useEffect(() => { + if (!hasAnimated) + return; + const timeoutId = setTimeout(() => { + if (isInView) + motionVal.set(number); + }, delay); + return () => clearTimeout(timeoutId); + }, [hasAnimated, isInView, number, motionVal, delay]); + + const [effectiveNumber, setEffectiveNumber] = React.useState(0); + + React.useEffect(() => { + if (hasAnimated) { + const inferredDecimals + = typeof decimalPlaces === "number" && decimalPlaces >= 0 + ? decimalPlaces + : (() => { + const s = String(number); + const idx = s.indexOf("."); + return idx >= 0 ? s.length - idx - 1 : 0; + })(); + + const factor = 10 ** inferredDecimals; + + const unsubscribe = springVal.on("change", (latest: number) => { + const newValue + = inferredDecimals > 0 + ? Math.round(latest * factor) / factor + : Math.round(latest); + + if (effectiveNumber !== newValue) { + setEffectiveNumber(newValue); + onNumberChange?.(newValue); + } + }); + return () => unsubscribe(); + } + else { + setEffectiveNumber(!isInView ? 0 : Math.abs(Number(number))); + } + }, [ + hasAnimated, + springVal, + isInView, + number, + decimalPlaces, + onNumberChange, + effectiveNumber, + ]); + + const formatNumber = React.useCallback( + (num: number) => + decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(), + [decimalPlaces], + ); + + const numberStr = formatNumber(effectiveNumber); + const [newIntStrRaw, newDecStrRaw = ""] = numberStr.split("."); + + const finalIntLength = padStart + ? Math.max( + Math.floor(Math.abs(number)).toString().length, + newIntStrRaw.length, + ) + : newIntStrRaw.length; + + const newIntStr = padStart + ? newIntStrRaw.padStart(finalIntLength, "0") + : newIntStrRaw; + + const prevFormatted = formatNumber(prevNumberRef.current); + const [prevIntStrRaw = "", prevDecStrRaw = ""] = prevFormatted.split("."); + const prevIntStr = padStart + ? prevIntStrRaw.padStart(finalIntLength, "0") + : prevIntStrRaw; + + const adjustedPrevInt = React.useMemo(() => { + return prevIntStr.length > finalIntLength + ? prevIntStr.slice(-finalIntLength) + : prevIntStr.padStart(finalIntLength, "0"); + }, [prevIntStr, finalIntLength]); + + const adjustedPrevDec = React.useMemo(() => { + if (!newDecStrRaw) + return ""; + return prevDecStrRaw.length > newDecStrRaw.length + ? prevDecStrRaw.slice(0, newDecStrRaw.length) + : prevDecStrRaw.padEnd(newDecStrRaw.length, "0"); + }, [prevDecStrRaw, newDecStrRaw]); + + React.useEffect(() => { + if (isInView) + prevNumberRef.current = effectiveNumber; + }, [effectiveNumber, isInView]); + + const intPlaces = React.useMemo( + () => + Array.from({ length: finalIntLength }, (_, i) => + 10 ** (finalIntLength - i - 1)), + [finalIntLength], + ); + const decPlaces = React.useMemo( + () => + newDecStrRaw + ? Array.from({ length: newDecStrRaw.length }, (_, i) => + 10 ** (newDecStrRaw.length - i - 1)) + : [], + [newDecStrRaw], + ); + + const newDecValue = newDecStrRaw ? Number.parseInt(newDecStrRaw, 10) : 0; + const prevDecValue = adjustedPrevDec ? Number.parseInt(adjustedPrevDec, 10) : 0; + + return ( + + {isInView && Number(number) < 0 && ( + - + )} + + {intPlaces.map((place, idx) => { + const digitsToRight = intPlaces.length - idx - 1; + const isSeparatorPosition + = typeof thousandSeparator !== "undefined" + && digitsToRight > 0 + && digitsToRight % 3 === 0; + + return ( + + + {isSeparatorPosition && {thousandSeparator}} + + ); + })} + + {newDecStrRaw && ( + <> + {decimalSeparator} + {decPlaces.map(place => ( + + ))} + + )} + + ); +} + +export { SlidingNumber, type SlidingNumberProps }; diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx index a6bbd8db3..994f144f2 100644 --- a/frontend/src/components/navbar.tsx +++ b/frontend/src/components/navbar.tsx @@ -7,7 +7,7 @@ import { navbarLinks } from "@/config/site-config"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; -import StarOnGithubButton from "./ui/star-on-github-button"; +import { GitHubStarsButton } from "./animate-ui/components/buttons/github-stars"; import { ThemeToggle } from "./ui/theme-toggle"; import CommandMenu from "./command-menu"; @@ -39,31 +39,18 @@ function Navbar() { href="/" className="flex cursor-pointer w-full justify-center sm:justify-start flex-row-reverse items-center gap-2 font-semibold sm:flex-row" > - logo + logo Proxmox VE Helper-Scripts
- + {navbarLinks.map(({ href, event, icon, text, mobileHidden }) => ( - +