diff --git a/surfsense_web/components/homepage/github-stars-badge.tsx b/surfsense_web/components/homepage/github-stars-badge.tsx new file mode 100644 index 000000000..cb4b55247 --- /dev/null +++ b/surfsense_web/components/homepage/github-stars-badge.tsx @@ -0,0 +1,481 @@ +"use client"; + +import * as React from "react"; +import { + motion, + AnimatePresence, + useInView, + useMotionValue, + useSpring, + useTransform, +} from "motion/react"; +import type { HTMLMotionProps, UseInViewOptions } from "motion/react"; +import { StarIcon } from "lucide-react"; +import { IconBrandGithub } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- +function getStrictContext(name?: string) { + const Context = React.createContext(undefined); + const Provider = ({ value, children }: { value: T; children?: React.ReactNode }) => ( + {children} + ); + const useSafeContext = () => { + const ctx = React.useContext(Context); + if (ctx === undefined) { + throw new Error(`useContext must be used within ${name ?? "a Provider"}`); + } + return ctx; + }; + return [Provider, useSafeContext] as const; +} + +interface UseIsInViewOptions { + inView?: boolean; + inViewOnce?: boolean; + inViewMargin?: UseInViewOptions["margin"]; +} + +function useIsInView( + ref: React.Ref, + options: UseIsInViewOptions = {} +) { + const { inView, inViewOnce = false, inViewMargin = "0px" } = options; + const localRef = React.useRef(null); + React.useImperativeHandle(ref, () => localRef.current as T); + const inViewResult = useInView(localRef, { + once: inViewOnce, + margin: inViewMargin, + }); + const isInView = !inView || inViewResult; + return { ref: localRef, isInView }; +} + +// --------------------------------------------------------------------------- +// Particles (for star burst effect on completion) +// --------------------------------------------------------------------------- +type ParticlesContextType = { animate: boolean; isInView: boolean }; +const [ParticlesProvider, useParticles] = + getStrictContext("ParticlesContext"); + +function Particles({ + ref, + animate = true, + inView = false, + inViewMargin = "0px", + inViewOnce = true, + children, + style, + ...props +}: Omit, "children"> & { + animate?: boolean; + children: React.ReactNode; +} & UseIsInViewOptions) { + const { ref: localRef, isInView } = useIsInView(ref as React.Ref, { + inView, + inViewOnce, + inViewMargin, + }); + return ( + + + {children} + + + ); +} + +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 +}: Omit, "children"> & { + side?: "top" | "bottom" | "left" | "right"; + align?: "start" | "center" | "end"; + count?: number; + radius?: number; + spread?: number; + duration?: number; + holdDelay?: number; + sideOffset?: number; + alignOffset?: number; + delay?: number; +}) { + 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(count)].map((_, i) => { + const angle = i * angleStep; + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + return ( + + ); + })} + + ); +} + +// --------------------------------------------------------------------------- +// Per-digit scrolling wheel +// --------------------------------------------------------------------------- +const ROLLING_ITEM_COUNT = 200; + +function DigitWheel({ + digit, + itemSize = 22, + delay = 0, + cycles = 5, + isRolling = false, + reverse = false, + className, + onSettled, +}: { + digit: number; + itemSize?: number; + delay?: number; + cycles?: number; + isRolling?: boolean; + reverse?: boolean; + className?: string; + onSettled?: () => void; +}) { + const sequence = React.useMemo(() => { + if (isRolling) { + return Array.from({ length: ROLLING_ITEM_COUNT }, (_, i) => ({ + id: `r${i}`, + value: i % 10, + })); + } + + const seq = Array.from({ length: cycles * 10 }, (_, i) => ({ + id: `s${i}`, + value: Math.floor(Math.random() * 10), + })); + const target = { id: "target", value: digit }; + if (reverse) { + seq.unshift(target); + } else { + seq.push(target); + } + return seq; + }, [digit, cycles, isRolling, reverse]); + + const maxOffset = (sequence.length - 1) * itemSize; + const endY = reverse ? 0 : -maxOffset; + + const rollingStartItem = React.useRef(Math.floor(Math.random() * 10)); + const startOffset = rollingStartItem.current * itemSize; + + const y = useMotionValue( + isRolling ? (reverse ? -(maxOffset - startOffset) : -startOffset) : reverse ? -maxOffset : 0 + ); + const ySpring = useSpring( + y, + isRolling ? { stiffness: 10000, damping: 500 } : { stiffness: 70, damping: 20 } + ); + const settledRef = React.useRef(false); + const wasRollingRef = React.useRef(isRolling); + + // Jump y to settling start position when transitioning from rolling → settled + React.useLayoutEffect(() => { + if (wasRollingRef.current && !isRolling) { + y.jump(reverse ? -maxOffset : 0); + } + wasRollingRef.current = isRolling; + }, [isRolling, reverse, maxOffset, y]); + + // Rolling: drive y continuously via RAF (stiff spring tracks it transparently) + React.useEffect(() => { + if (!isRolling) return; + + const cycleHeight = 10 * itemSize; + const msPerCycle = 1000; + let startTime: number | null = null; + let rafId: number; + + const tick = (time: number) => { + if (startTime === null) startTime = time; + const elapsed = time - startTime; + const speed = cycleHeight / msPerCycle; + const travel = elapsed * speed + startOffset; + + if (reverse) { + y.set(Math.min(-maxOffset + travel, 0)); + } else { + y.set(Math.max(-travel, -maxOffset)); + } + + rafId = requestAnimationFrame(tick); + }; + + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [isRolling, itemSize, reverse, y, maxOffset, startOffset]); + + // Settling: spring to endY after delay + React.useEffect(() => { + if (isRolling) return; + settledRef.current = false; + const timer = setTimeout(() => y.set(endY), delay); + return () => clearTimeout(timer); + }, [endY, y, delay, isRolling]); + + // Detect settled + React.useEffect(() => { + if (isRolling) return; + const unsub = ySpring.on("change", (latest) => { + if (!settledRef.current && Math.abs(latest - endY) < 0.5) { + settledRef.current = true; + onSettled?.(); + } + }); + return unsub; + }, [ySpring, endY, onSettled, isRolling]); + + return ( +
+ + {sequence.map((item) => ( +
+ {item.value} +
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Animated star count with per-digit alternating wheels +// --------------------------------------------------------------------------- +const numberFormatter = new Intl.NumberFormat("en-US"); + +function AnimatedStarCount({ + value, + itemSize = 22, + isRolling = false, + className, + onComplete, +}: { + value: number; + itemSize?: number; + isRolling?: boolean; + className?: string; + onComplete?: () => void; +}) { + const formatted = numberFormatter.format(value); + const chars = formatted.split(""); + + let totalDigits = 0; + for (const c of chars) { + if (c >= "0" && c <= "9") totalDigits++; + } + + const settledCount = React.useRef(0); + const completedRef = React.useRef(false); + + const handleDigitSettled = React.useCallback(() => { + settledCount.current++; + if (!completedRef.current && settledCount.current >= totalDigits) { + completedRef.current = true; + onComplete?.(); + } + }, [totalDigits, onComplete]); + + let digitIndex = 0; + let separatorIndex = 0; + + return ( +
+ {chars.map((char) => { + if (char < "0" || char > "9") { + const sepKey = `sep-${separatorIndex++}`; + return ( +
+ {char} +
+ ); + } + const digit = parseInt(char, 10); + const idx = digitIndex++; + return ( + + ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// NavbarGitHubStars — the exported component +// --------------------------------------------------------------------------- +const ITEM_SIZE = 22; + +type NavbarGitHubStarsProps = { + username?: string; + repo?: string; + href?: string; + className?: string; +}; + +function NavbarGitHubStars({ + username = "MODSetter", + repo = "SurfSense", + href = "https://github.com/MODSetter/SurfSense", + className, +}: NavbarGitHubStarsProps) { + const [stars, setStars] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(true); + const [isCompleted, setIsCompleted] = React.useState(false); + + const fillRaw = useMotionValue(0); + const fillSpring = useSpring(fillRaw, { stiffness: 12, damping: 14 }); + const clipPath = useTransform(fillSpring, (v) => `inset(${100 - v * 100}% 0 0 0)`); + + React.useEffect(() => { + const abortController = new AbortController(); + fetch(`https://api.github.com/repos/${username}/${repo}`, { + signal: abortController.signal, + }) + .then((res) => res.json()) + .then((data) => { + if (data && typeof data.stargazers_count === "number") { + setStars(data.stargazers_count); + fillRaw.set(1); + } + }) + .catch((err) => { + if (err instanceof Error && err.name !== "AbortError") { + console.error("Error fetching stars:", err); + } + }) + .finally(() => setIsLoading(false)); + return () => abortController.abort(); + }, [username, repo, fillRaw]); + + return ( + + +
+ setIsCompleted(true)} + /> + +
+
+ +
+
+
+ ); +} + +export { NavbarGitHubStars, type NavbarGitHubStarsProps }; diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index ddf43e7eb..f711c9103 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -1,18 +1,12 @@ "use client"; -import { - IconBrandDiscord, - IconBrandGithub, - IconBrandReddit, - IconMenu2, - IconX, -} from "@tabler/icons-react"; +import { IconBrandDiscord, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { SignInButton } from "@/components/auth/sign-in-button"; import { Logo } from "@/components/Logo"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; -import { useGithubStars } from "@/hooks/use-github-stars"; +import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge"; import { cn } from "@/lib/utils"; export const Navbar = () => { @@ -47,7 +41,6 @@ export const Navbar = () => { const DesktopNav = ({ navItems, isScrolled }: any) => { const [hovered, setHovered] = useState(null); - const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars(); return ( { @@ -103,21 +96,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => { > - - - {loadingGithubStars ? ( -
- ) : ( - - {githubStars} - - )} - + @@ -127,7 +106,6 @@ const DesktopNav = ({ navItems, isScrolled }: any) => { const MobileNav = ({ navItems, isScrolled }: any) => { const [open, setOpen] = useState(false); - const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars(); return ( { > - - - {loadingGithubStars ? ( -
- ) : ( - - {githubStars} - - )} - + diff --git a/surfsense_web/hooks/use-github-stars.ts b/surfsense_web/hooks/use-github-stars.ts deleted file mode 100644 index aa2bad1b9..000000000 --- a/surfsense_web/hooks/use-github-stars.ts +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -export const useGithubStars = () => { - const [stars, setStars] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const abortController = new AbortController(); - const getStars = async () => { - try { - setError(null); - - const response = await fetch(`https://api.github.com/repos/MODSetter/SurfSense`, { - signal: abortController.signal, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch stars: ${response.statusText}`); - } - - const data = await response.json(); - - setStars(data?.stargazers_count); - } catch (err) { - // Ignore abort errors (expected on unmount) - if (err instanceof Error && err.name === "AbortError") { - return; - } - if (err instanceof Error) { - console.error("Error fetching stars:", err); - setError(err.message); - } - } finally { - setLoading(false); - } - }; - - getStars(); - - return () => { - abortController.abort("Component unmounted"); - }; - }, []); - - return { - stars, - loading, - error, - compactFormat: Intl.NumberFormat("en-US", { - notation: "compact", - maximumFractionDigits: 1, - minimumFractionDigits: 1, - }).format(stars || 0), - }; -};